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 d2a147b50..bc7fde4ae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -66,7 +66,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/project/analytics/', '/src/app/features/project/contributors/', diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ccf75a496..afba7d027 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -15,7 +15,7 @@ import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; -import { MetaTagsService } from './shared/services/meta-tags.service'; +import { MetaTagsService } from './shared/services'; @Component({ selector: 'osf-root', diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index 314cd1b31..efcc4338b 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -353,15 +353,15 @@ export const MENU_ITEMS: MenuItem[] = [ visible: false, items: [ { - id: 'settings-profile-settings', - routerLink: '/settings/profile-settings', + id: 'settings-profile', + routerLink: '/settings/profile', label: 'navigation.profileSettings', visible: true, routerLinkActiveOptions: { exact: true }, }, { - id: 'settings-account-settings', - routerLink: '/settings/account-settings', + id: 'settings-account', + routerLink: '/settings/account', label: 'navigation.accountSettings', visible: true, routerLinkActiveOptions: { exact: true }, 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/features/project/settings/components/index.ts b/src/app/features/project/settings/components/index.ts index b1d1cc40d..48f7a5372 100644 --- a/src/app/features/project/settings/components/index.ts +++ b/src/app/features/project/settings/components/index.ts @@ -3,7 +3,6 @@ export { ProjectSettingNotificationsComponent } from './project-setting-notifica export { SettingsAccessRequestsCardComponent } from './settings-access-requests-card/settings-access-requests-card.component'; export { SettingsProjectAffiliationComponent } from './settings-project-affiliation/settings-project-affiliation.component'; export { SettingsProjectFormCardComponent } from './settings-project-form-card/settings-project-form-card.component'; -export { SettingsRedirectLinkComponent } from './settings-redirect-link/settings-redirect-link.component'; export { SettingsStorageLocationCardComponent } from './settings-storage-location-card/settings-storage-location-card.component'; export { SettingsViewOnlyLinksCardComponent } from './settings-view-only-links-card/settings-view-only-links-card.component'; export { SettingsWikiCardComponent } from './settings-wiki-card/settings-wiki-card.component'; diff --git a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.html b/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.html deleted file mode 100644 index e89fe4f2f..000000000 --- a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.html +++ /dev/null @@ -1,39 +0,0 @@ - -

{{ 'myProjects.settings.redirectLink' | translate }}

- -
-
- - - -
- - @if (redirectForm.get('isEnabled')?.value) { -
- - - -
- -
- -
- } -
-
diff --git a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.spec.ts b/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.spec.ts deleted file mode 100644 index 73ad22188..000000000 --- a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SettingsRedirectLinkComponent } from './settings-redirect-link.component'; - -describe('SettingsRedirectLinkComponent', () => { - let component: SettingsRedirectLinkComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SettingsRedirectLinkComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SettingsRedirectLinkComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.ts b/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.ts deleted file mode 100644 index c208f2c75..000000000 --- a/src/app/features/project/settings/components/settings-redirect-link/settings-redirect-link.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { Card } from 'primeng/card'; -import { Checkbox } from 'primeng/checkbox'; - -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; - -import { TextInputComponent } from '@osf/shared/components'; -import { InputLimits } from '@osf/shared/constants'; -import { CustomValidators } from '@osf/shared/helpers'; - -import { RedirectLinkDataModel, RedirectLinkForm } from '../../models'; - -@Component({ - selector: 'osf-settings-redirect-link', - imports: [Card, Checkbox, TranslatePipe, ReactiveFormsModule, TextInputComponent, Button], - templateUrl: './settings-redirect-link.component.html', - styleUrl: '../../settings.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SettingsRedirectLinkComponent { - private readonly destroyRef = inject(DestroyRef); - - redirectUrlDataInput = input.required(); - redirectUrlDataChange = output(); - - inputLimits = InputLimits; - - redirectForm = new FormGroup({ - isEnabled: new FormControl(false, { - nonNullable: true, - validators: [Validators.required], - }), - url: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.linkValidator()]), - label: new FormControl('', [CustomValidators.requiredTrimmed()]), - }); - - constructor() { - this.setupFormSubscriptions(); - this.setupInputEffects(); - } - - private setupFormSubscriptions(): void { - this.redirectForm.controls.isEnabled?.valueChanges - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((isEnabled) => { - if (!isEnabled) { - this.redirectForm.get('url')?.setValue(''); - this.redirectForm.get('label')?.setValue(''); - this.emitFormData(); - } - }); - } - - saveRedirectSettings(): void { - if (this.redirectForm.valid) { - this.emitFormData(); - } - } - - private setupInputEffects(): void { - effect(() => { - const inputData = this.redirectUrlDataInput(); - this.redirectForm.patchValue( - { - isEnabled: inputData.isEnabled, - url: inputData.url, - label: inputData.label, - }, - { emitEvent: false } - ); - - this.redirectForm.markAsPristine(); - }); - } - - get hasChanges(): boolean { - return this.redirectForm.dirty; - } - - private emitFormData(): void { - const formValue = this.redirectForm.value; - this.redirectUrlDataChange.emit({ - isEnabled: formValue.isEnabled || false, - url: formValue.url || '', - label: formValue.label || '', - }); - } -} diff --git a/src/app/features/project/settings/mappers/settings.mapper.ts b/src/app/features/project/settings/mappers/settings.mapper.ts index 9ebc60308..96429ca98 100644 --- a/src/app/features/project/settings/mappers/settings.mapper.ts +++ b/src/app/features/project/settings/mappers/settings.mapper.ts @@ -21,9 +21,6 @@ export class SettingsMapper { accessRequestsEnabled: result.attributes.access_requests_enabled, anyoneCanComment: result.attributes.anyone_can_comment, anyoneCanEditWiki: result.attributes.anyone_can_edit_wiki, - redirectLinkEnabled: result.attributes.redirect_link_enabled, - redirectLinkLabel: result.attributes.redirect_link_label, - redirectLinkUrl: result.attributes.redirect_link_url, wikiEnabled: result.attributes.wiki_enabled, }, } as ProjectSettingsModel; diff --git a/src/app/features/project/settings/models/index.ts b/src/app/features/project/settings/models/index.ts index 282b281d1..5d0d33700 100644 --- a/src/app/features/project/settings/models/index.ts +++ b/src/app/features/project/settings/models/index.ts @@ -3,6 +3,4 @@ export * from './project-details.model'; export * from './project-details-json-api.model'; export * from './project-settings.model'; export * from './project-settings-response.model'; -export * from './redirect-link-data.model'; -export * from './redirect-link-form.model'; export * from './right-control.model'; diff --git a/src/app/features/project/settings/models/project-settings-response.model.ts b/src/app/features/project/settings/models/project-settings-response.model.ts index 49c6255c2..5a27c0e97 100644 --- a/src/app/features/project/settings/models/project-settings-response.model.ts +++ b/src/app/features/project/settings/models/project-settings-response.model.ts @@ -3,9 +3,6 @@ export interface ProjectSettingsAttributes { anyone_can_comment: boolean; anyone_can_edit_wiki: boolean; wiki_enabled: boolean; - redirect_link_enabled: boolean; - redirect_link_url: string; - redirect_link_label: string; } export interface RelatedLink { diff --git a/src/app/features/project/settings/models/project-settings.model.ts b/src/app/features/project/settings/models/project-settings.model.ts index d59109d38..8b565e6bc 100644 --- a/src/app/features/project/settings/models/project-settings.model.ts +++ b/src/app/features/project/settings/models/project-settings.model.ts @@ -4,9 +4,6 @@ export interface ProjectSettingsModel { accessRequestsEnabled: boolean; anyoneCanComment: boolean; anyoneCanEditWiki: boolean; - redirectLinkEnabled: boolean; - redirectLinkLabel: string; - redirectLinkUrl: string; wikiEnabled: boolean; }; lastFetched?: number; diff --git a/src/app/features/project/settings/models/redirect-link-data.model.ts b/src/app/features/project/settings/models/redirect-link-data.model.ts deleted file mode 100644 index f85e7b63b..000000000 --- a/src/app/features/project/settings/models/redirect-link-data.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface RedirectLinkDataModel { - isEnabled: boolean; - url: string; - label: string; -} diff --git a/src/app/features/project/settings/models/redirect-link-form.model.ts b/src/app/features/project/settings/models/redirect-link-form.model.ts deleted file mode 100644 index 1e9deaa7e..000000000 --- a/src/app/features/project/settings/models/redirect-link-form.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FormControl } from '@angular/forms'; - -export interface RedirectLinkForm { - isEnabled: FormControl; - url: FormControl; - label: FormControl; -} diff --git a/src/app/features/project/settings/settings.component.html b/src/app/features/project/settings/settings.component.html index 5584dfb98..9b82a527c 100644 --- a/src/app/features/project/settings/settings.component.html +++ b/src/app/features/project/settings/settings.component.html @@ -43,11 +43,6 @@ [notifications]="notifications()" /> - - ({ isEnabled: false, url: '', label: '' }); accessRequest = signal(false); wikiEnabled = signal(false); anyoneCanEditWiki = signal(false); @@ -146,11 +143,6 @@ export class SettingsComponent implements OnInit { this.syncSettingsChanges('anyone_can_edit_wiki', newValue); } - onRedirectUrlDataRequestChange(data: RedirectLinkDataModel): void { - this.redirectUrlData.set(data); - this.syncSettingsChanges('redirectUrl', data); - } - onNotificationRequestChange(data: { event: SubscriptionEvent; frequency: SubscriptionFrequency }): void { const id = `${this.projectId()}_${data.event}`; const frequency = data.frequency; @@ -208,24 +200,16 @@ export class SettingsComponent implements OnInit { }); } - private syncSettingsChanges(changedField: string, value: boolean | RedirectLinkDataModel): void { + private syncSettingsChanges(changedField: string, value: boolean): void { const payload: Partial = {}; switch (changedField) { case 'access_requests_enabled': case 'wiki_enabled': - case 'redirect_link_enabled': case 'anyone_can_edit_wiki': case 'anyone_can_comment': payload[changedField] = value as boolean; break; - case 'redirectUrl': - if (typeof value === 'object') { - payload['redirect_link_enabled'] = value.isEnabled; - payload['redirect_link_url'] = value.isEnabled ? value.url : undefined; - payload['redirect_link_label'] = value.isEnabled ? value.label : undefined; - } - break; } const model = { @@ -251,12 +235,6 @@ export class SettingsComponent implements OnInit { this.wikiEnabled.set(settings.attributes.wikiEnabled); this.anyoneCanEditWiki.set(settings.attributes.anyoneCanEditWiki); this.anyoneCanComment.set(settings.attributes.anyoneCanComment); - - this.redirectUrlData.set({ - isEnabled: settings.attributes.redirectLinkEnabled, - url: settings.attributes.redirectLinkUrl, - label: settings.attributes.redirectLinkLabel, - }); } }); diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index 8e2debb7c..b3ede123b 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -34,6 +34,7 @@ [componentsList]="componentsWikiList()" [currentWikiId]="currentWikiId()" [viewOnly]="hasViewOnly()" + [showAddBtn]="true" (createWiki)="onCreateWiki()" (deleteWiki)="onDeleteWiki()" > diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html index 4983d95fb..d5b12579b 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -14,7 +14,9 @@

{{ currentResource()?.description }}

@@ -25,7 +27,7 @@

{{ resourceName }}

class="btn-full-width" [label]="'common.buttons.edit' | translate" severity="info" - (click)="backToEdit()" + (onClick)="backToEdit()" /> ({ + form = new FormGroup({ pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), @@ -60,7 +60,7 @@ export class AddResourceDialogComponent { public resourceOptions = signal(resourceTypeOptions); public isPreviewMode = signal(false); - protected readonly RegistryResourceType = RegistryResourceType; + readonly RegistryResourceType = RegistryResourceType; previewResource(): void { if (this.form.invalid) { @@ -78,9 +78,7 @@ export class AddResourceDialogComponent { throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); } - this.actions.previewResource(currentResource.id, addResource).subscribe(() => { - this.isPreviewMode.set(true); - }); + this.actions.previewResource(currentResource.id, addResource).subscribe(() => this.isPreviewMode.set(true)); } backToEdit() { @@ -88,9 +86,7 @@ export class AddResourceDialogComponent { } onAddResource() { - const addResource: ConfirmAddResource = { - finalized: true, - }; + const addResource: ConfirmAddResource = { finalized: true }; const currentResource = this.currentResource(); if (!currentResource) { diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts index eae30ae18..f5b0900a7 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -24,15 +24,15 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export class EditResourceDialogComponent { - protected readonly dialogRef = inject(DynamicDialogRef); - protected readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); + readonly dialogRef = inject(DynamicDialogRef); + readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); private translateService = inject(TranslateService); private dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; - protected form = new FormGroup({ + form = new FormGroup({ pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html index e15247772..d0e49c9fe 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html @@ -38,7 +38,7 @@

{{ 'project.overview.metadata.contributors' | translate }}: @for (contributor of registrationData().contributors; track contributor.id) { - {{ contributor.fullName }} + {{ contributor.fullName }} @if (!$last) { , } diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts index 02831f81a..5c597ef20 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts @@ -5,6 +5,7 @@ import { Card } from 'primeng/card'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { DataResourcesComponent, IconComponent, TruncatedTextComponent } from '@osf/shared/components'; import { RevisionReviewStates } from '@osf/shared/enums'; @@ -13,7 +14,16 @@ import { LinkedNode, LinkedRegistration, RegistryComponentModel } from '../../mo @Component({ selector: 'osf-registration-links-card', - imports: [Card, Button, TranslatePipe, DatePipe, DataResourcesComponent, TruncatedTextComponent, IconComponent], + imports: [ + Card, + Button, + TranslatePipe, + DatePipe, + DataResourcesComponent, + TruncatedTextComponent, + IconComponent, + RouterLink, + ], templateUrl: './registration-links-card.component.html', styleUrl: './registration-links-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -24,24 +34,24 @@ export class RegistrationLinksCardComponent { readonly updateEmitRegistrationData = output(); readonly reviewEmitRegistrationData = output(); - protected readonly RevisionReviewStates = RevisionReviewStates; + readonly RevisionReviewStates = RevisionReviewStates; - protected readonly isRegistrationData = computed(() => { + readonly isRegistrationData = computed(() => { const data = this.registrationData(); return 'reviewsState' in data; }); - protected readonly isComponentData = computed(() => { + readonly isComponentData = computed(() => { const data = this.registrationData(); return 'registrationSupplement' in data; }); - protected readonly registrationDataTyped = computed(() => { + readonly registrationDataTyped = computed(() => { const data = this.registrationData(); return this.isRegistrationData() ? (data as LinkedRegistration) : null; }); - protected readonly componentsDataTyped = computed(() => { + readonly componentsDataTyped = computed(() => { const data = this.registrationData(); return this.isComponentData() ? (data as RegistryComponentModel) : null; }); diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts index b25e836f7..741b7efa3 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts @@ -48,21 +48,19 @@ import { RegistryOverviewSelectors, SubmitDecision } from '../../store/registry- }) export class RegistryMakeDecisionComponent { private readonly fb = inject(FormBuilder); - protected readonly config = inject(DynamicDialogConfig); - protected readonly dialogRef = inject(DynamicDialogRef); + readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); - protected readonly ReviewActionTrigger = ReviewActionTrigger; - protected readonly SchemaResponseActionTrigger = SchemaResponseActionTrigger; - protected readonly SubmissionReviewStatus = SubmissionReviewStatus; - protected readonly ModerationDecisionFormControls = ModerationDecisionFormControls; - protected reviewActions = select(RegistryOverviewSelectors.getReviewActions); + readonly ReviewActionTrigger = ReviewActionTrigger; + readonly SchemaResponseActionTrigger = SchemaResponseActionTrigger; + readonly SubmissionReviewStatus = SubmissionReviewStatus; + readonly ModerationDecisionFormControls = ModerationDecisionFormControls; + reviewActions = select(RegistryOverviewSelectors.getReviewActions); - protected isSubmitting = select(RegistryOverviewSelectors.isReviewActionSubmitting); - protected requestForm!: FormGroup; + isSubmitting = select(RegistryOverviewSelectors.isReviewActionSubmitting); + requestForm!: FormGroup; - protected actions = createDispatchMap({ - submitDecision: SubmitDecision, - }); + actions = createDispatchMap({ submitDecision: SubmitDecision }); registry = this.config.data.registry as RegistryOverview; embargoEndDate = this.registry.embargoEndDate; @@ -105,7 +103,7 @@ export class RegistryMakeDecisionComponent { }); } - protected handleSubmission(): void { + handleSubmission(): void { const revisionId = this.config.data.revisionId; this.actions .submitDecision( @@ -120,7 +118,7 @@ export class RegistryMakeDecisionComponent { .subscribe(); } - protected isCommentRequired(action: string): boolean { + isCommentRequired(action: string): boolean { return ( action === ReviewActionTrigger.RejectSubmission || action === SchemaResponseActionTrigger.RejectRevision || diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts index 761ca39ff..a028e61f3 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts @@ -19,13 +19,16 @@ import { RevisionReviewStates } from '@shared/enums'; }) export class RegistryRevisionsComponent { @HostBinding('class') classes = 'flex-1 flex'; + registry = input.required(); selectedRevisionIndex = input.required(); isSubmitting = input(false); isModeration = input(false); openRevision = output(); + readonly updateRegistration = output(); readonly continueUpdate = output(); + readonly RevisionReviewStates = RevisionReviewStates; unApprovedRevisionId: string | null = null; @@ -76,7 +79,6 @@ export class RegistryRevisionsComponent { this.openRevision.emit(index); } - protected readonly RevisionReviewStates = RevisionReviewStates; continueUpdateHandler(): void { this.continueUpdate.emit(); } diff --git a/src/app/features/registry/components/registry-statuses/registry-statuses.component.scss b/src/app/features/registry/components/registry-statuses/registry-statuses.component.scss index 51e2630d2..4e711ed03 100644 --- a/src/app/features/registry/components/registry-statuses/registry-statuses.component.scss +++ b/src/app/features/registry/components/registry-statuses/registry-statuses.component.scss @@ -1,8 +1,7 @@ -@use "/styles/variables" as var; @use "styles/mixins" as mix; .accordion-border { - border: 1px solid var.$grey-2; + border: 1px solid var(--grey-2); border-radius: mix.rem(12px); height: max-content !important; } diff --git a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts index e3048d041..31f9b0537 100644 --- a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts +++ b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts @@ -9,13 +9,13 @@ import { DialogService } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, computed, HostBinding, inject, input } from '@angular/core'; import { Router } from '@angular/router'; -import { WithdrawDialogComponent } from '@osf/features/registry/components'; -import { RegistryOverview } from '@osf/features/registry/models'; -import { MakePublic } from '@osf/features/registry/store/registry-overview'; -import { RegistrationReviewStates, RevisionReviewStates } from '@osf/shared/enums'; -import { RegistryStatus } from '@shared/enums'; -import { hasViewOnlyParam } from '@shared/helpers'; -import { CustomConfirmationService } from '@shared/services'; +import { RegistrationReviewStates, RegistryStatus, RevisionReviewStates } from '@osf/shared/enums'; +import { hasViewOnlyParam } from '@osf/shared/helpers'; +import { CustomConfirmationService } from '@osf/shared/services'; + +import { RegistryOverview } from '../../models'; +import { MakePublic } from '../../store/registry-overview'; +import { WithdrawDialogComponent } from '../withdraw-dialog/withdraw-dialog.component'; @Component({ selector: 'osf-registry-statuses', @@ -27,15 +27,15 @@ import { CustomConfirmationService } from '@shared/services'; export class RegistryStatusesComponent { @HostBinding('class') classes = 'flex-1 flex'; private readonly router = inject(Router); - registry = input.required(); private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); + + registry = input.required(); + readonly RegistryStatus = RegistryStatus; readonly RevisionReviewStates = RevisionReviewStates; readonly customConfirmationService = inject(CustomConfirmationService); - readonly actions = createDispatchMap({ - makePublic: MakePublic, - }); + readonly actions = createDispatchMap({ makePublic: MakePublic }); get canWithdraw(): boolean { return ( diff --git a/src/app/features/registry/components/resource-form/resource-form.component.html b/src/app/features/registry/components/resource-form/resource-form.component.html index 0aceb3622..0f8b5ff09 100644 --- a/src/app/features/registry/components/resource-form/resource-form.component.html +++ b/src/app/features/registry/components/resource-form/resource-form.component.html @@ -20,7 +20,14 @@
- +
@@ -29,7 +36,7 @@ class="btn-full-width" [label]="cancelButtonLabel() | translate" severity="info" - (click)="handleCancel()" + (onClick)="handleCancel()" /> } (); submitClicked = output(); - protected inputLimits = InputLimits; - public resourceOptions = signal(resourceTypeOptions); + inputLimits = InputLimits; + resourceOptions = signal(resourceTypeOptions); - protected getControl(controlName: keyof RegistryResourceFormModel): FormControl { + getControl(controlName: keyof RegistryResourceFormModel): FormControl { return this.formGroup().get(controlName) as FormControl; } diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts index d20a7ef4e..7ebd33717 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts @@ -16,7 +16,8 @@ import { environment } from 'src/environments/environment'; }) export class ShortRegistrationInfoComponent { registration = input.required(); - protected readonly environment = environment; + + readonly environment = environment; get associatedProjectUrl(): string { return `${this.environment.webUrl}/${this.registration().associatedProjectId}`; diff --git a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts index 37f7e7299..fb97c3fc4 100644 --- a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts +++ b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts @@ -22,16 +22,12 @@ import { TextInputComponent } from '@shared/components'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class WithdrawDialogComponent { - protected readonly dialogRef = inject(DynamicDialogRef); + readonly dialogRef = inject(DynamicDialogRef); private readonly config = inject(DynamicDialogConfig); - private readonly actions = createDispatchMap({ - withdrawRegistration: WithdrawRegistration, - }); - - protected readonly form = new FormGroup({ - text: new FormControl(''), - }); - protected readonly inputLimits = InputLimits; + private readonly actions = createDispatchMap({ withdrawRegistration: WithdrawRegistration }); + + readonly form = new FormGroup({ text: new FormControl('') }); + readonly inputLimits = InputLimits; withdrawRegistration(): void { const registryId = this.config.data.registryId; diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.ts b/src/app/features/registry/pages/registry-components/registry-components.component.ts index 0dc699230..167d5c40c 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.ts +++ b/src/app/features/registry/pages/registry-components/registry-components.component.ts @@ -5,15 +5,14 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistrationLinksCardComponent } from '@osf/features/registry/components'; -import { RegistryComponentModel } from '@osf/features/registry/models'; -import { GetRegistryById, RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; -import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; -import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; -import { hasViewOnlyParam } from '@shared/helpers'; +import { LoadingSpinnerComponent, SubHeaderComponent, ViewOnlyLinkMessageComponent } from '@osf/shared/components'; +import { hasViewOnlyParam } from '@osf/shared/helpers'; +import { RegistrationLinksCardComponent } from '../../components'; +import { RegistryComponentModel } from '../../models'; import { GetRegistryComponents, RegistryComponentsSelectors } from '../../store/registry-components'; import { GetBibliographicContributorsForRegistration, RegistryLinksSelectors } from '../../store/registry-links'; +import { GetRegistryById, RegistryOverviewSelectors } from '../../store/registry-overview'; @Component({ selector: 'osf-registry-components', diff --git a/src/app/features/registry/pages/registry-links/registry-links.component.ts b/src/app/features/registry/pages/registry-links/registry-links.component.ts index e0e725b14..65b777ec0 100644 --- a/src/app/features/registry/pages/registry-links/registry-links.component.ts +++ b/src/app/features/registry/pages/registry-links/registry-links.component.ts @@ -8,11 +8,11 @@ import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } fr import { ActivatedRoute, Router } from '@angular/router'; import { FetchAllSchemaResponses, RegistriesSelectors } from '@osf/features/registries/store'; -import { RegistrationLinksCardComponent } from '@osf/features/registry/components'; -import { LinkedNode, LinkedRegistration } from '@osf/features/registry/models'; -import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; -import { LoaderService } from '@shared/services'; +import { LoadingSpinnerComponent, SubHeaderComponent } from '@osf/shared/components'; +import { LoaderService } from '@osf/shared/services'; +import { RegistrationLinksCardComponent } from '../../components'; +import { LinkedNode, LinkedRegistration } from '../../models'; import { GetBibliographicContributors, GetBibliographicContributorsForRegistration, @@ -35,7 +35,7 @@ export class RegistryLinksComponent implements OnInit { private registryId = signal(''); - protected actions = createDispatchMap({ + actions = createDispatchMap({ getLinkedNodes: GetLinkedNodes, getLinkedRegistrations: GetLinkedRegistrations, getBibliographicContributors: GetBibliographicContributors, @@ -46,23 +46,21 @@ export class RegistryLinksComponent implements OnInit { nodes = signal([]); registrations = signal([]); - protected linkedNodes = select(RegistryLinksSelectors.getLinkedNodes); - protected linkedNodesLoading = select(RegistryLinksSelectors.getLinkedNodesLoading); + linkedNodes = select(RegistryLinksSelectors.getLinkedNodes); + linkedNodesLoading = select(RegistryLinksSelectors.getLinkedNodesLoading); - protected linkedRegistrations = select(RegistryLinksSelectors.getLinkedRegistrations); - protected linkedRegistrationsLoading = select(RegistryLinksSelectors.getLinkedRegistrationsLoading); + linkedRegistrations = select(RegistryLinksSelectors.getLinkedRegistrations); + linkedRegistrationsLoading = select(RegistryLinksSelectors.getLinkedRegistrationsLoading); - protected bibliographicContributors = select(RegistryLinksSelectors.getBibliographicContributors); - protected bibliographicContributorsNodeId = select(RegistryLinksSelectors.getBibliographicContributorsNodeId); + bibliographicContributors = select(RegistryLinksSelectors.getBibliographicContributors); + bibliographicContributorsNodeId = select(RegistryLinksSelectors.getBibliographicContributorsNodeId); - protected bibliographicContributorsForRegistration = select( - RegistryLinksSelectors.getBibliographicContributorsForRegistration - ); - protected bibliographicContributorsForRegistrationId = select( + bibliographicContributorsForRegistration = select(RegistryLinksSelectors.getBibliographicContributorsForRegistration); + bibliographicContributorsForRegistrationId = select( RegistryLinksSelectors.getBibliographicContributorsForRegistrationId ); - protected schemaResponse = select(RegistriesSelectors.getSchemaResponse); + schemaResponse = select(RegistriesSelectors.getSchemaResponse); constructor() { effect(() => { diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html index 1ca013a57..add727823 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -28,7 +28,7 @@

{{ resourceName }}

- https://doi.org/{{ resource.pid }} + https://doi.org/{{ resource.pid }}

{{ resource.description }}

diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts index 7ccd81da4..18aac974f 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -34,16 +34,17 @@ import { export class RegistryResourcesComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); - private dialogService = inject(DialogService); - private translateService = inject(TranslateService); - private toastService = inject(ToastService); - private customConfirmationService = inject(CustomConfirmationService); + private readonly dialogService = inject(DialogService); + private readonly translateService = inject(TranslateService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); - protected readonly resources = select(RegistryResourcesSelectors.getResources); - protected readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); - private registryId = ''; - protected addingResource = signal(false); - private readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + readonly resources = select(RegistryResourcesSelectors.getResources); + readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); + readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + + registryId = ''; + addingResource = signal(false); private readonly actions = createDispatchMap({ getResources: GetRegistryResources, @@ -62,9 +63,7 @@ export class RegistryResourcesComponent { } addResource() { - if (!this.registryId) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); - } + if (!this.registryId) return; this.addingResource.set(true); @@ -91,10 +90,10 @@ export class RegistryResourcesComponent { this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'); } else { const currentResource = this.currentResource(); - if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); + + if (currentResource) { + this.actions.silentDelete(currentResource.id); } - this.actions.silentDelete(currentResource.id); } }, error: () => this.toastService.showError('resources.toastMessages.addResourceError'), @@ -103,9 +102,7 @@ export class RegistryResourcesComponent { } updateResource(resource: RegistryResource) { - if (!this.registryId) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); - } + if (!this.registryId) return; const dialogRef = this.dialogService.open(EditResourceDialogComponent, { header: this.translateService.instant('resources.edit'), @@ -138,9 +135,7 @@ export class RegistryResourcesComponent { this.actions .deleteResource(id, this.registryId) .pipe(take(1)) - .subscribe(() => { - this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess'); - }); + .subscribe(() => this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess')); }, }); } diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 01bedd68c..2f5ee68ad 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -1,4 +1,4 @@ - + + @if (wikiModes().view) { } + @if (wikiModes().compare) { { - return hasViewOnlyParam(this.router); - }); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); readonly resourceId = this.route.parent?.snapshot.params['id']; diff --git a/src/app/features/registry/store/registry-components/registry-components.model.ts b/src/app/features/registry/store/registry-components/registry-components.model.ts index a285b7853..d07b37b34 100644 --- a/src/app/features/registry/store/registry-components/registry-components.model.ts +++ b/src/app/features/registry/store/registry-components/registry-components.model.ts @@ -5,3 +5,7 @@ import { RegistryComponentModel } from '../../models'; export interface RegistryComponentsStateModel { registryComponents: AsyncStateWithTotalCount; } + +export const REGISTRY_COMPONENTS_STATE_DEFAULTS: RegistryComponentsStateModel = { + registryComponents: { data: [], isLoading: false, error: null, totalCount: 0 }, +}; diff --git a/src/app/features/registry/store/registry-components/registry-components.state.ts b/src/app/features/registry/store/registry-components/registry-components.state.ts index fa46b1b32..d3914fcbd 100644 --- a/src/app/features/registry/store/registry-components/registry-components.state.ts +++ b/src/app/features/registry/store/registry-components/registry-components.state.ts @@ -9,15 +9,11 @@ import { handleSectionError } from '@shared/helpers'; import { RegistryComponentsService } from '../../services/registry-components.service'; import { GetRegistryComponents } from './registry-components.actions'; -import { RegistryComponentsStateModel } from './registry-components.model'; - -const initialState: RegistryComponentsStateModel = { - registryComponents: { data: [], isLoading: false, error: null, totalCount: 0 }, -}; +import { REGISTRY_COMPONENTS_STATE_DEFAULTS, RegistryComponentsStateModel } from './registry-components.model'; @State({ name: 'registryComponents', - defaults: initialState, + defaults: REGISTRY_COMPONENTS_STATE_DEFAULTS, }) @Injectable() export class RegistryComponentsState { diff --git a/src/app/features/registry/store/registry-links/registry-links.model.ts b/src/app/features/registry/store/registry-links/registry-links.model.ts index f9056747f..1ce7f12e7 100644 --- a/src/app/features/registry/store/registry-links/registry-links.model.ts +++ b/src/app/features/registry/store/registry-links/registry-links.model.ts @@ -16,3 +16,10 @@ export interface RegistryLinksStateModel { registrationId?: string; }; } + +export const REGISTRY_LINKS_STATE_DEFAULTS: RegistryLinksStateModel = { + linkedNodes: { data: [], isLoading: false, error: null }, + linkedRegistrations: { data: [], isLoading: false, error: null }, + bibliographicContributors: { data: [], isLoading: false, error: null }, + bibliographicContributorsForRegistration: { data: [], isLoading: false, error: null }, +}; diff --git a/src/app/features/registry/store/registry-links/registry-links.state.ts b/src/app/features/registry/store/registry-links/registry-links.state.ts index 9713ea7eb..5cf9945a9 100644 --- a/src/app/features/registry/store/registry-links/registry-links.state.ts +++ b/src/app/features/registry/store/registry-links/registry-links.state.ts @@ -14,18 +14,11 @@ import { GetLinkedNodes, GetLinkedRegistrations, } from './registry-links.actions'; -import { RegistryLinksStateModel } from './registry-links.model'; - -const initialState: RegistryLinksStateModel = { - linkedNodes: { data: [], isLoading: false, error: null }, - linkedRegistrations: { data: [], isLoading: false, error: null }, - bibliographicContributors: { data: [], isLoading: false, error: null }, - bibliographicContributorsForRegistration: { data: [], isLoading: false, error: null }, -}; +import { REGISTRY_LINKS_STATE_DEFAULTS, RegistryLinksStateModel } from './registry-links.model'; @State({ name: 'registryLinks', - defaults: initialState, + defaults: REGISTRY_LINKS_STATE_DEFAULTS, }) @Injectable() export class RegistryLinksState { diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts index dc349dbd1..10decd081 100644 --- a/src/app/features/settings/settings.routes.ts +++ b/src/app/features/settings/settings.routes.ts @@ -17,15 +17,15 @@ export const settingsRoutes: Routes = [ { path: '', pathMatch: 'full', - redirectTo: 'profile-settings', + redirectTo: 'profile', }, { - path: 'profile-settings', + path: 'profile', loadComponent: () => import('./profile-settings/profile-settings.component').then((c) => c.ProfileSettingsComponent), }, { - path: 'account-settings', + path: 'account', loadComponent: () => import('./account-settings/account-settings.component').then((c) => c.AccountSettingsComponent), }, 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 bba444f89..a2903fd98 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.ts +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.ts @@ -13,7 +13,7 @@ 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'; // avoids circular imports +import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; @Component({ selector: 'osf-resource-metadata', diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index cf8b708ba..30265bcc8 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -11,15 +11,21 @@ } @else { @if (expanded()) { -
- +
+ @if (showAddBtn()) { + + } + - + />
@if (!viewOnly()) { @if (!isHomeWikiSelected() || !list().length) { @@ -39,8 +44,7 @@ outlined (onClick)="openDeleteWikiDialog()" class="mb-2 flex" - > -
+ /> } }
@@ -72,19 +76,22 @@

{{ item.label | translate }}

} @else {
- +
- + + @if (showAddBtn()) { + + } } }
diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts index 3f27d32ac..63ce9bd5d 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts @@ -24,12 +24,14 @@ import { AddWikiDialogComponent } from '../add-wiki-dialog/add-wiki-dialog.compo providers: [DialogService], }) export class WikiListComponent { - readonly viewOnly = input(false); - readonly resourceId = input.required(); readonly list = input.required(); - readonly isLoading = input(false); - readonly componentsList = input.required(); + readonly resourceId = input.required(); readonly currentWikiId = input.required(); + readonly componentsList = input.required(); + + readonly showAddBtn = input(false); + readonly isLoading = input(false); + readonly viewOnly = input(false); readonly deleteWiki = output(); readonly createWiki = output(); 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/mappers/user/user.mapper.ts b/src/app/shared/mappers/user/user.mapper.ts index 552354044..cbf232ec3 100644 --- a/src/app/shared/mappers/user/user.mapper.ts +++ b/src/app/shared/mappers/user/user.mapper.ts @@ -34,7 +34,7 @@ export class UserMapper { social: user.attributes.social, defaultRegionId: user.relationships?.default_region?.data?.id, allowIndexing: user.attributes?.allow_indexing, - canViewReviews: user.attributes.can_view_reviews === true, //do not simplify it + canViewReviews: user.attributes.can_view_reviews === true, // [NS] Do not simplify it }; } 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 298d616d6..38e062ea4 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -418,8 +418,6 @@ "wikiConfigureText": "Create a link to share this project so those who have the link can view—but not edit—the project.", "emailNotifications": "Email Notifications", "emailNotificationsText": "These notification settings only apply to you. They do NOT affect any other contributor on this project.", - "redirectLink": "Redirect Link", - "redirectLinkText": "Redirect visitors from your project page to an external webpage", "projectAffiliation": "Project Affiliation / Branding", "projectsCanBeAffiliated": "Projects can be affiliated with institutions that have created OSF for Institutions accounts. This allows:", "institutionalLogos": "institutional logos to be displayed on public projects", @@ -432,7 +430,6 @@ "url": "URL", "label": "Label", "storageLocationMessage": "Storage location cannot be changed after project is created.", - "redirectUrlPlaceholder": "Send people who visit your OSF project page to this link instead", "invalidUrl": "Please enter a valid URL, such as: https://example.com", "disabledForWiki": "This feature is disabled for wikis of private projects.", "enabledForWiki": "This feature is enabled for wikis of private projects.", @@ -969,7 +966,8 @@ "actions": { "downloadAsZip": "Download As Zip", "createFolder": "Create Folder", - "uploadFile": "Upload File" + "uploadFile": "Upload File", + "addFromDrive": "Add from Drive" }, "dialogs": { "uploadFile": { @@ -2696,9 +2694,9 @@ "edit": "Edit Resource", "delete": "Delete Resource", "check": "Check your DOI for accuracy", - "deleteText": "Are you sure you want to delete resource", + "deleteText": "Are you sure you want to delete resource? This cannot be undone.", "selectAResourceType": "Select A Resource Type", - "descriptionLabel": "Description(Optional)", + "descriptionLabel": "Description (Optional)", "typeOptions": { "data": "Data", "code": "Analytic Code", 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, + }; +}