diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 4756b8699..89fea762c 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -9,6 +9,10 @@ export const serverRoutes: ServerRoute[] = [ path: 'privacy-policy', renderMode: RenderMode.Prerender, }, + { + path: 'choose-repository', + renderMode: RenderMode.Prerender, + }, { path: 'forbidden', renderMode: RenderMode.Prerender, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index a227f8e5a..ace56b109 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -60,6 +60,13 @@ export const routes: Routes = [ ), data: { skipBreadcrumbs: true }, }, + { + path: 'choose-repository', + loadComponent: () => + import('./features/home/pages/choose-repository/choose-repository.component').then( + (mod) => mod.ChooseRepositoryComponent + ), + }, { path: 'search', loadComponent: () => import('./features/search/search.component').then((mod) => mod.SearchComponent), diff --git a/src/app/core/constants/storage-keys.const.ts b/src/app/core/constants/storage-keys.const.ts new file mode 100644 index 000000000..fe770d2ea --- /dev/null +++ b/src/app/core/constants/storage-keys.const.ts @@ -0,0 +1,4 @@ +export const STORAGE_KEYS = { + currentUser: 'currentUser', + activeFlags: 'activeFlags', +} as const; diff --git a/src/app/core/services/storage.service.ts b/src/app/core/services/storage.service.ts index f30868b89..9a67f7e86 100644 --- a/src/app/core/services/storage.service.ts +++ b/src/app/core/services/storage.service.ts @@ -1,11 +1,35 @@ import { isPlatformBrowser } from '@angular/common'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { STORAGE_KEYS } from '@core/constants/storage-keys.const'; +import { UserModel } from '@osf/shared/models/user/user.model'; + @Injectable({ providedIn: 'root' }) export class StorageService { - private platformId = inject(PLATFORM_ID); + private readonly platformId = inject(PLATFORM_ID); + + getCachedUser(): UserModel | null { + return this.getJson(STORAGE_KEYS.currentUser); + } + + setCachedUser(user: UserModel): void { + this.setJson(STORAGE_KEYS.currentUser, user); + } - getItem(key: string): string | null { + getCachedActiveFlags(): string[] { + return this.getJson(STORAGE_KEYS.activeFlags) ?? []; + } + + setCachedActiveFlags(flags: string[]): void { + this.setJson(STORAGE_KEYS.activeFlags, flags); + } + + clearSession(): void { + this.removeItem(STORAGE_KEYS.currentUser); + this.removeItem(STORAGE_KEYS.activeFlags); + } + + private getItem(key: string): string | null { if (isPlatformBrowser(this.platformId)) { return window.localStorage.getItem(key); } @@ -13,15 +37,34 @@ export class StorageService { return null; } - setItem(key: string, value: string): void { + private setItem(key: string, value: string): void { if (isPlatformBrowser(this.platformId)) { window.localStorage.setItem(key, value); } } - removeItem(key: string): void { + private removeItem(key: string): void { if (isPlatformBrowser(this.platformId)) { window.localStorage.removeItem(key); } } + + private getJson(key: string): T | null { + const raw = this.getItem(key); + + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as T; + } catch { + this.removeItem(key); + return null; + } + } + + private setJson(key: string, value: T): void { + this.setItem(key, JSON.stringify(value)); + } } diff --git a/src/app/core/services/user.service.spec.ts b/src/app/core/services/user.service.spec.ts new file mode 100644 index 000000000..bb4051a74 --- /dev/null +++ b/src/app/core/services/user.service.spec.ts @@ -0,0 +1,163 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of, Subject } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; +import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; +import { UserMapper } from '@osf/shared/mappers/user'; +import { UserData } from '@osf/shared/models/user/user.model'; +import { + UserAcceptedTermsOfServiceJsonApi, + UserDataResponseJsonApi, +} from '@osf/shared/models/user/user-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { getCurrentUserData, getUserDataJsonApi } from '@testing/data/user/user.data'; +import { MOCK_USER } from '@testing/mocks/data.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { ENVIRONMENT } from '../provider/environment.provider'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + let jsonApiService: JsonApiServiceMockType; + + const apiResponse = getCurrentUserData(); + + const mappedUserData: UserData = { + activeFlags: [FEATURE_FLAGS.WORKFLOW_LAUNCHER], + currentUser: MOCK_USER, + }; + + beforeEach(() => { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + UserService, + MockProvider(JsonApiService, jsonApiService), + MockProvider(ENVIRONMENT, { apiDomainUrl: 'https://api.test' }), + ], + }); + + service = TestBed.inject(UserService); + }); + + it('should expose apiUrl from environment', () => { + expect(service.apiUrl).toBe('https://api.test/v2'); + }); + + it('should fetch and map current user from /v2/', async () => { + jsonApiService.get.mockReturnValue(of(apiResponse)); + const mapperSpy = vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData); + + const result = await firstValueFrom(service.getCurrentUser()); + + expect(jsonApiService.get).toHaveBeenCalledWith('https://api.test/v2/'); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(result).toEqual(mappedUserData); + }); + + it('should share in-flight getCurrentUser request across concurrent subscribers', async () => { + const response$ = new Subject(); + jsonApiService.get.mockReturnValue(response$.asObservable()); + vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData); + + const first = firstValueFrom(service.getCurrentUser()); + const second = firstValueFrom(service.getCurrentUser()); + + expect(jsonApiService.get).toHaveBeenCalledTimes(1); + + response$.next(apiResponse); + response$.complete(); + + await expect(first).resolves.toEqual(mappedUserData); + await expect(second).resolves.toEqual(mappedUserData); + }); + + it('should fetch again after previous getCurrentUser completes', async () => { + jsonApiService.get.mockReturnValue(of(apiResponse)); + vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData); + + await firstValueFrom(service.getCurrentUser()); + await firstValueFrom(service.getCurrentUser()); + + expect(jsonApiService.get).toHaveBeenCalledTimes(2); + }); + + it('should fetch again after resetCurrentUserCache', async () => { + const response$ = new Subject(); + jsonApiService.get.mockReturnValue(response$.asObservable()); + vi.spyOn(UserMapper, 'fromUserDataGetResponse').mockReturnValue(mappedUserData); + + const first = firstValueFrom(service.getCurrentUser()); + expect(jsonApiService.get).toHaveBeenCalledTimes(1); + + service.resetCurrentUserCache(); + + const second = firstValueFrom(service.getCurrentUser()); + expect(jsonApiService.get).toHaveBeenCalledTimes(2); + + response$.next(apiResponse); + response$.complete(); + + await expect(first).resolves.toEqual(mappedUserData); + await expect(second).resolves.toEqual(mappedUserData); + }); + + it('should fetch and map user by id', async () => { + const userResponse = { data: getUserDataJsonApi() }; + jsonApiService.get.mockReturnValue(of(userResponse)); + const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER); + + const result = await firstValueFrom(service.getUserById(MOCK_USER.id)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`); + expect(mapperSpy).toHaveBeenCalledWith(userResponse.data); + expect(result).toEqual(MOCK_USER); + }); + + it('should patch user profile attributes', async () => { + const userResponse = getUserDataJsonApi(); + jsonApiService.patch.mockReturnValue(of(userResponse)); + const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER); + const employment = MOCK_USER.employment; + + const result = await firstValueFrom( + service.updateUserProfile(MOCK_USER.id, ProfileSettingsKey.Employment, employment) + ); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`, { + data: { + type: 'users', + id: MOCK_USER.id, + attributes: { [ProfileSettingsKey.Employment]: employment }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(userResponse); + expect(result).toEqual(MOCK_USER); + }); + + it('should patch accepted terms of service', async () => { + const userResponse = getUserDataJsonApi(); + jsonApiService.patch.mockReturnValue(of(userResponse)); + const mapperSpy = vi.spyOn(UserMapper, 'fromUserGetResponse').mockReturnValue(MOCK_USER); + const payload: UserAcceptedTermsOfServiceJsonApi = { accepted_terms_of_service: true }; + + const result = await firstValueFrom(service.updateUserAcceptedTermsOfService(MOCK_USER.id, payload)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`https://api.test/v2/users/${MOCK_USER.id}/`, { + data: { + type: 'users', + id: MOCK_USER.id, + attributes: payload, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(userResponse); + expect(result).toEqual(MOCK_USER); + }); +}); diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index 3506c67cc..92f422116 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -1,4 +1,4 @@ -import { map, Observable } from 'rxjs'; +import { finalize, map, Observable, shareReplay } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -21,6 +21,7 @@ import { export class UserService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); + private currentUserRequest: Observable | null = null; get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; @@ -33,9 +34,21 @@ export class UserService { } getCurrentUser(): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/`) - .pipe(map((response) => UserMapper.fromUserDataGetResponse(response))); + if (!this.currentUserRequest) { + this.currentUserRequest = this.jsonApiService.get(`${this.apiUrl}/`).pipe( + map((response) => UserMapper.fromUserDataGetResponse(response)), + finalize(() => { + this.currentUserRequest = null; + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + } + + return this.currentUserRequest; + } + + resetCurrentUserCache(): void { + this.currentUserRequest = null; } updateUserProfile(userId: string, key: string, data: ProfileSettingsUpdate): Observable { diff --git a/src/app/core/store/user/user.selectors.spec.ts b/src/app/core/store/user/user.selectors.spec.ts new file mode 100644 index 000000000..31ad01d3e --- /dev/null +++ b/src/app/core/store/user/user.selectors.spec.ts @@ -0,0 +1,114 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TestBed } from '@angular/core/testing'; + +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; + +import { MOCK_USER } from '@testing/mocks/data.mock'; + +import { USER_STATE_INITIAL, UserStateModel } from './user.model'; +import { UserSelectors } from './user.selectors'; +import { UserState } from './user.state'; + +describe('UserSelectors', () => { + let store: Store; + + const setUserState = (userState: UserStateModel) => { + store.reset({ + ...store.snapshot(), + user: userState, + }); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideStore([UserState])], + }); + + store = TestBed.inject(Store); + }); + + it('should select default logged out state', () => { + setUserState(USER_STATE_INITIAL); + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toBeNull(); + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + expect(store.selectSnapshot(UserSelectors.getShareIndexing)).toBeUndefined(); + expect(store.selectSnapshot(UserSelectors.getUserNames)).toBeNull(); + expect(store.selectSnapshot(UserSelectors.getEmployment)).toEqual([]); + expect(store.selectSnapshot(UserSelectors.getEducation)).toEqual([]); + expect(store.selectSnapshot(UserSelectors.getSocialLinks)).toBeUndefined(); + expect(store.selectSnapshot(UserSelectors.getCanViewReviews)).toBe(false); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(false); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([]); + }); + + it('should select current user and authentication state', () => { + setUserState({ + currentUser: { + data: MOCK_USER, + isLoading: false, + error: null, + }, + activeFlags: [FEATURE_FLAGS.WORKFLOW_LAUNCHER], + }); + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toEqual(MOCK_USER); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(true); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([FEATURE_FLAGS.WORKFLOW_LAUNCHER]); + }); + + it('should select current user loading state', () => { + setUserState({ + currentUser: { + data: null, + isLoading: true, + error: null, + }, + activeFlags: [], + }); + + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(true); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(false); + }); + + it('should select user profile fields', () => { + setUserState({ + currentUser: { + data: MOCK_USER, + isLoading: false, + error: null, + }, + activeFlags: [], + }); + + expect(store.selectSnapshot(UserSelectors.getUserNames)).toEqual(MOCK_USER); + expect(store.selectSnapshot(UserSelectors.getEmployment)).toEqual(MOCK_USER.employment); + expect(store.selectSnapshot(UserSelectors.getEducation)).toEqual(MOCK_USER.education); + expect(store.selectSnapshot(UserSelectors.getSocialLinks)).toEqual(MOCK_USER.social); + expect(store.selectSnapshot(UserSelectors.getShareIndexing)).toBe(true); + expect(store.selectSnapshot(UserSelectors.getCanViewReviews)).toBe(true); + }); + + it('should default can view reviews to false when user flag is missing', () => { + setUserState({ + currentUser: { + data: { ...MOCK_USER, canViewReviews: undefined as unknown as boolean }, + isLoading: false, + error: null, + }, + activeFlags: [], + }); + + expect(store.selectSnapshot(UserSelectors.getCanViewReviews)).toBe(false); + }); + + it('should default active flags to empty array when state value is missing', () => { + setUserState({ + currentUser: USER_STATE_INITIAL.currentUser, + activeFlags: undefined as unknown as string[], + }); + + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([]); + }); +}); diff --git a/src/app/core/store/user/user.state.spec.ts b/src/app/core/store/user/user.state.spec.ts new file mode 100644 index 000000000..9516e0f87 --- /dev/null +++ b/src/app/core/store/user/user.state.spec.ts @@ -0,0 +1,289 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { defaultIfEmpty, firstValueFrom } from 'rxjs'; + +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { StorageService } from '@core/services/storage.service'; +import { UserService } from '@core/services/user.service'; +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; +import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; +import { removeNullable } from '@osf/shared/helpers/remove-nullable.helper'; +import { UserMapper } from '@osf/shared/mappers/user'; + +import { + getAcceptedTermsUserDataJsonApi, + getCurrentUserData, + getLoggedOutCurrentUserData, + getUserDataJsonApi, +} from '@testing/data/user/user.data'; +import { MOCK_USER } from '@testing/mocks/data.mock'; +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; +import { StorageServiceMock, StorageServiceMockType } from '@testing/providers/storage.service.mock'; + +import { + AcceptTermsOfServiceByUser, + ClearCurrentUser, + GetCurrentUser, + SetCurrentUser, + UpdateProfileSettingsEducation, + UpdateProfileSettingsEmployment, + UpdateProfileSettingsSocialLinks, + UpdateProfileSettingsUser, +} from './user.actions'; +import { UserSelectors } from './user.selectors'; +import { UserState } from './user.state'; + +describe('State: User', () => { + const currentUserApiUrl = 'http://localhost:8000/v2/'; + const userApiUrl = `http://localhost:8000/v2/users/${MOCK_USER.id}/`; + + let store: Store; + let storageService: StorageServiceMockType; + let httpMock: HttpTestingController; + + beforeEach(() => { + storageService = StorageServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + provideOSFHttp(), + provideStore([UserState]), + UserService, + MockProvider(StorageService, storageService), + ], + }); + + store = TestBed.inject(Store); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('getCurrentUser should hydrate cached session, fetch current user, and update selectors', async () => { + storageService.getCachedUser.mockReturnValue(MOCK_USER); + storageService.getCachedActiveFlags.mockReturnValue(['cached_flag']); + + const dispatchPromise = firstValueFrom(store.dispatch(GetCurrentUser)); + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toEqual(MOCK_USER); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual(['cached_flag']); + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + + const request = httpMock.expectOne(currentUserApiUrl); + expect(request.request.method).toBe('GET'); + request.flush(getCurrentUserData()); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toEqual( + UserMapper.fromUserGetResponse(getUserDataJsonApi()) + ); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([FEATURE_FLAGS.WORKFLOW_LAUNCHER]); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(true); + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + expect(storageService.setCachedActiveFlags).toHaveBeenCalledWith([FEATURE_FLAGS.WORKFLOW_LAUNCHER]); + expect(storageService.setCachedUser).toHaveBeenCalledWith(UserMapper.fromUserGetResponse(getUserDataJsonApi())); + }); + + it('getCurrentUser should set loading while fetching when no cached user exists', async () => { + const dispatchPromise = firstValueFrom(store.dispatch(GetCurrentUser)); + + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(true); + + const request = httpMock.expectOne(currentUserApiUrl); + request.flush(getCurrentUserData()); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(true); + }); + + it('getCurrentUser should clear current user when api returns no user and no cache exists', async () => { + const dispatchPromise = firstValueFrom(store.dispatch(GetCurrentUser)); + + const request = httpMock.expectOne(currentUserApiUrl); + request.flush(getLoggedOutCurrentUserData()); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toBeNull(); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([]); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(false); + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + expect(storageService.setCachedActiveFlags).toHaveBeenCalledWith([]); + expect(storageService.setCachedUser).not.toHaveBeenCalled(); + }); + + it('getCurrentUser should keep cached user when api returns no user but cache exists', async () => { + storageService.getCachedUser.mockReturnValue(MOCK_USER); + storageService.getCachedActiveFlags.mockReturnValue(['cached_flag']); + + const dispatchPromise = firstValueFrom(store.dispatch(GetCurrentUser)); + + const request = httpMock.expectOne(currentUserApiUrl); + request.flush(getLoggedOutCurrentUserData()); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toEqual(MOCK_USER); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([]); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(true); + expect(storageService.setCachedActiveFlags).toHaveBeenCalledWith([]); + expect(storageService.setCachedUser).not.toHaveBeenCalled(); + }); + + it('setCurrentUser should set current user in state and cache', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toEqual(MOCK_USER); + expect(store.selectSnapshot(UserSelectors.getCurrentUserLoading)).toBe(false); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(true); + expect(storageService.setCachedUser).toHaveBeenCalledWith(MOCK_USER); + }); + + it('clearCurrentUser should clear current user, active flags, and session cache', async () => { + const userService = TestBed.inject(UserService); + const resetCacheSpy = vi.spyOn(userService, 'resetCurrentUserCache'); + + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + await firstValueFrom(store.dispatch(ClearCurrentUser)); + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)).toBeNull(); + expect(store.selectSnapshot(UserSelectors.getActiveFlags)).toEqual([]); + expect(store.selectSnapshot(UserSelectors.isAuthenticated)).toBe(false); + expect(storageService.clearSession).toHaveBeenCalled(); + expect(resetCacheSpy).toHaveBeenCalled(); + }); + + it('updateProfileSettingsEmployment should not call api when current user is missing', async () => { + await firstValueFrom( + store.dispatch(new UpdateProfileSettingsEmployment(MOCK_USER.employment)).pipe(defaultIfEmpty(null)) + ); + + httpMock.expectNone(userApiUrl); + expect(storageService.setCachedUser).not.toHaveBeenCalled(); + }); + + it('updateProfileSettingsEmployment should update employment and persist current user', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + const employment = MOCK_USER.employment.map((item) => removeNullable(item)); + + const dispatchPromise = firstValueFrom(store.dispatch(new UpdateProfileSettingsEmployment(MOCK_USER.employment))); + + const request = httpMock.expectOne(userApiUrl); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ + data: { + type: 'users', + id: MOCK_USER.id, + attributes: { [ProfileSettingsKey.Employment]: employment }, + }, + }); + request.flush({ data: getUserDataJsonApi() }); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getEmployment)).toEqual(MOCK_USER.employment); + expect(storageService.setCachedUser).toHaveBeenCalledWith(UserMapper.fromUserGetResponse(getUserDataJsonApi())); + }); + + it('updateProfileSettingsEducation should update education and persist current user', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + + const dispatchPromise = firstValueFrom(store.dispatch(new UpdateProfileSettingsEducation(MOCK_USER.education))); + + const request = httpMock.expectOne(userApiUrl); + expect(request.request.method).toBe('PATCH'); + request.flush({ data: getUserDataJsonApi() }); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getEducation)).toEqual(MOCK_USER.education); + expect(storageService.setCachedUser).toHaveBeenCalledWith(UserMapper.fromUserGetResponse(getUserDataJsonApi())); + }); + + it('updateProfileSettingsUser should update user profile names and persist current user', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + const payload = { + fullName: 'Jane Doe', + givenName: 'Jane', + familyName: 'Doe', + }; + + const dispatchPromise = firstValueFrom(store.dispatch(new UpdateProfileSettingsUser(payload))); + + const request = httpMock.expectOne(userApiUrl); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ + data: { + type: 'users', + id: MOCK_USER.id, + attributes: UserMapper.toNamesRequest(payload), + }, + }); + request.flush({ data: getUserDataJsonApi() }); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getUserNames)?.fullName).toBe('John Doe'); + expect(storageService.setCachedUser).toHaveBeenCalledWith(UserMapper.fromUserGetResponse(getUserDataJsonApi())); + }); + + it('updateProfileSettingsSocialLinks should merge social links and persist current user', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + const socialUpdates = [{ github: ['https://github.com/janedoe'] }, { twitter: ['https://twitter.com/janedoe'] }]; + + const dispatchPromise = firstValueFrom(store.dispatch(new UpdateProfileSettingsSocialLinks(socialUpdates))); + + const request = httpMock.expectOne(userApiUrl); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ + data: { + type: 'users', + id: MOCK_USER.id, + attributes: { + [ProfileSettingsKey.Social]: { + github: ['https://github.com/janedoe'], + twitter: ['https://twitter.com/janedoe'], + }, + }, + }, + }); + request.flush({ data: getUserDataJsonApi() }); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getSocialLinks)).toEqual(MOCK_USER.social); + expect(storageService.setCachedUser).toHaveBeenCalledWith(UserMapper.fromUserGetResponse(getUserDataJsonApi())); + }); + + it('acceptTermsOfServiceByUser should not call api when current user is missing', async () => { + await firstValueFrom(store.dispatch(AcceptTermsOfServiceByUser).pipe(defaultIfEmpty(null))); + + httpMock.expectNone(userApiUrl); + expect(storageService.setCachedUser).not.toHaveBeenCalled(); + }); + + it('acceptTermsOfServiceByUser should accept terms of service and persist current user', async () => { + await firstValueFrom(store.dispatch(new SetCurrentUser(MOCK_USER))); + + const dispatchPromise = firstValueFrom(store.dispatch(AcceptTermsOfServiceByUser)); + + const request = httpMock.expectOne(userApiUrl); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual({ + data: { + type: 'users', + id: MOCK_USER.id, + attributes: { accepted_terms_of_service: true }, + }, + }); + request.flush({ data: getAcceptedTermsUserDataJsonApi() }); + await dispatchPromise; + + expect(store.selectSnapshot(UserSelectors.getCurrentUser)?.acceptedTermsOfService).toBe(true); + expect(storageService.setCachedUser).toHaveBeenCalledWith( + UserMapper.fromUserGetResponse(getAcceptedTermsUserDataJsonApi()) + ); + }); +}); diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index c3b65d803..b08ac3894 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -9,7 +9,8 @@ import { UserService } from '@core/services/user.service'; import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; import { removeNullable } from '@osf/shared/helpers/remove-nullable.helper'; import { UserMapper } from '@osf/shared/mappers/user'; -import { UserModel } from '@osf/shared/models/user/user.model'; +import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; +import { UserData, UserModel } from '@osf/shared/models/user/user.model'; import { SocialModel } from '@shared/models/user/social.model'; import { @@ -30,74 +31,23 @@ import { USER_STATE_INITIAL, UserStateModel } from './user.model'; }) @Injectable() export class UserState { - private userService = inject(UserService); - private storage = inject(StorageService); + private readonly userService = inject(UserService); + private readonly storageService = inject(StorageService); @Action(GetCurrentUser) getCurrentUser(ctx: StateContext) { - const currentUser = this.storage.getItem('currentUser'); - const activeFlags = this.storage.getItem('activeFlags'); + const hadCachedUser = this.hydrateFromStorage(ctx); - if (activeFlags) { - ctx.patchState({ - activeFlags: JSON.parse(activeFlags), - }); - } - - if (currentUser) { - const parsedUser = JSON.parse(currentUser); - - ctx.patchState({ - currentUser: { - data: parsedUser, - isLoading: false, - error: null, - }, - }); - - return; - } - - ctx.patchState({ - currentUser: { - ...ctx.getState().currentUser, - isLoading: true, - }, - }); - - return this.userService.getCurrentUser().pipe( - tap((data) => { - ctx.patchState({ - currentUser: { - data: data.currentUser, - isLoading: false, - error: null, - }, - activeFlags: data.activeFlags, - }); - - if (data.currentUser) { - this.storage.setItem('currentUser', JSON.stringify(data.currentUser)); - } - - if (data.activeFlags) { - this.storage.setItem('activeFlags', JSON.stringify(data.activeFlags)); - } - }) - ); + return this.userService.getCurrentUser().pipe(tap((data) => this.applySession(ctx, data, hadCachedUser))); } @Action(SetCurrentUser) setCurrentUser(ctx: StateContext, action: SetCurrentUser) { ctx.patchState({ - currentUser: { - data: action.user, - isLoading: false, - error: null, - }, + currentUser: this.toUserState(action.user, false), }); - this.storage.setItem('currentUser', JSON.stringify(action.user)); + this.storageService.setCachedUser(action.user); } @Action(UpdateProfileSettingsEmployment) @@ -113,14 +63,7 @@ export class UserState { return this.userService.updateUserProfile(userId, ProfileSettingsKey.Employment, withoutNulls).pipe( tap((user) => { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: user, - }, - }); - - this.storage.setItem('currentUser', JSON.stringify(user)); + this.updateCurrentUser(ctx, state, user); }) ); } @@ -138,14 +81,7 @@ export class UserState { return this.userService.updateUserProfile(userId, ProfileSettingsKey.Education, withoutNulls).pipe( tap((user) => { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: user, - }, - }); - - this.storage.setItem('currentUser', JSON.stringify(user)); + this.updateCurrentUser(ctx, state, user); }) ); } @@ -163,14 +99,7 @@ export class UserState { return this.userService.updateUserProfile(userId, ProfileSettingsKey.User, withoutNulls).pipe( tap((user) => { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: user, - }, - }); - - this.storage.setItem('currentUser', JSON.stringify(user)); + this.updateCurrentUser(ctx, state, user); }) ); } @@ -195,14 +124,7 @@ export class UserState { return this.userService.updateUserProfile(userId, ProfileSettingsKey.Social, social).pipe( tap((user) => { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: user, - }, - }); - - this.storage.setItem('currentUser', JSON.stringify(user)); + this.updateCurrentUser(ctx, state, user); }) ); } @@ -233,7 +155,7 @@ export class UserState { }, }, }); - this.storage.setItem('currentUser', JSON.stringify(response)); + this.storageService.setCachedUser(response); } }) ); @@ -241,15 +163,66 @@ export class UserState { @Action(ClearCurrentUser) clearCurrentUser(ctx: StateContext) { + this.userService.resetCurrentUserCache(); + + ctx.patchState({ + currentUser: this.toUserState(null, false), + activeFlags: [], + }); + + this.storageService.clearSession(); + } + + private hydrateFromStorage(ctx: StateContext): boolean { + const cachedUser = this.storageService.getCachedUser(); + + ctx.patchState({ + activeFlags: this.storageService.getCachedActiveFlags(), + currentUser: cachedUser + ? this.toUserState(cachedUser, false) + : { ...ctx.getState().currentUser, isLoading: true }, + }); + + return !!cachedUser; + } + + private applySession(ctx: StateContext, data: UserData, hadCachedUser: boolean): void { + const activeFlags = data.activeFlags ?? []; + + this.storageService.setCachedActiveFlags(activeFlags); + + if (data.currentUser) { + this.storageService.setCachedUser(data.currentUser); + ctx.patchState({ + activeFlags, + currentUser: this.toUserState(data.currentUser, false), + }); + return; + } + + if (!hadCachedUser) { + ctx.patchState({ + activeFlags, + currentUser: this.toUserState(null, false), + }); + return; + } + + ctx.patchState({ activeFlags }); + } + + private updateCurrentUser(ctx: StateContext, state: UserStateModel, user: UserModel): void { ctx.patchState({ currentUser: { - data: null, - isLoading: false, - error: null, + ...state.currentUser, + data: user, }, - activeFlags: [], }); - this.storage.removeItem('currentUser'); + this.storageService.setCachedUser(user); + } + + private toUserState(data: UserModel | null, isLoading: boolean): AsyncStateModel { + return { data, isLoading, error: null }; } } diff --git a/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.html b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.html new file mode 100644 index 000000000..120c6a751 --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.html @@ -0,0 +1,16 @@ +
+
+ +

{{ card().titleKey | translate }}

+
+ +
+

{{ card().descriptionKey | translate }}

+ + +
+
diff --git a/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.scss b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.scss new file mode 100644 index 000000000..7dada0c8e --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.scss @@ -0,0 +1,51 @@ +.workflow-launcher-card { + background-color: var(--white); + border: 1px solid var(--grey-2); + border-radius: 0.5rem; + overflow: hidden; +} + +.card-header { + background-color: var(--workflow-launcher-card-header-bg); +} + +.card-icon { + color: var(--workflow-launcher-card-icon-color); +} + +.card-button { + --p-button-primary-background: var(--workflow-launcher-card-button-bg); + --p-button-primary-hover-background: var(--workflow-launcher-card-button-hover-bg); + --p-button-primary-active-background: var(--workflow-launcher-card-button-hover-bg); + --p-button-primary-border-color: var(--workflow-launcher-card-button-bg); + --p-button-primary-hover-border-color: var(--workflow-launcher-card-button-hover-bg); + --p-button-primary-active-border-color: var(--workflow-launcher-card-button-hover-bg); +} + +.workflow-launcher-card--blue { + --workflow-launcher-card-header-bg: var(--bg-blue-2); + --workflow-launcher-card-icon-color: var(--blue-2); + --workflow-launcher-card-button-bg: var(--blue-2); + --workflow-launcher-card-button-hover-bg: var(--pr-blue-3); +} + +.workflow-launcher-card--teal { + --workflow-launcher-card-header-bg: var(--blue-1-bg); + --workflow-launcher-card-icon-color: var(--blue-1); + --workflow-launcher-card-button-bg: var(--blue-1); + --workflow-launcher-card-button-hover-bg: var(--blue-3); +} + +.workflow-launcher-card--teal-green { + --workflow-launcher-card-header-bg: var(--green-2-bg); + --workflow-launcher-card-icon-color: var(--green-2-alt); + --workflow-launcher-card-button-bg: var(--green-2-alt); + --workflow-launcher-card-button-hover-bg: var(--green-1-alt); +} + +.workflow-launcher-card--green { + --workflow-launcher-card-header-bg: var(--green-1-bg); + --workflow-launcher-card-icon-color: var(--green-1-alt); + --workflow-launcher-card-button-bg: var(--green-1-alt); + --workflow-launcher-card-button-hover-bg: var(--green-2-alt); +} diff --git a/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.spec.ts b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.spec.ts new file mode 100644 index 000000000..8ef45c030 --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { WorkflowLauncherCard, WorkflowLauncherCardTheme } from '../../models/workflow-launcher-card.model'; + +import { WorkflowLauncherCardComponent } from './workflow-launcher-card.component'; + +describe('WorkflowLauncherCardComponent', () => { + let component: WorkflowLauncherCardComponent; + let fixture: ComponentFixture; + + const mockCard: WorkflowLauncherCard = { + iconClass: 'custom-icon-registries', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.button', + theme: WorkflowLauncherCardTheme.Blue, + routerLink: '/registries/osf/new', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [WorkflowLauncherCardComponent], + providers: [provideOSFCore(), provideRouter([])], + }); + + fixture = TestBed.createComponent(WorkflowLauncherCardComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.componentRef.setInput('card', mockCard); + expect(component).toBeTruthy(); + }); + + it('should expose the card input value', () => { + fixture.componentRef.setInput('card', mockCard); + + expect(component.card()).toEqual(mockCard); + }); + + it('should update the card input value', () => { + fixture.componentRef.setInput('card', mockCard); + + const updatedCard: WorkflowLauncherCard = { + iconClass: 'custom-icon-preprints', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.button', + theme: WorkflowLauncherCardTheme.TealGreen, + routerLink: '/preprints/select', + }; + + fixture.componentRef.setInput('card', updatedCard); + + expect(component.card()).toEqual(updatedCard); + }); +}); diff --git a/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.ts b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.ts new file mode 100644 index 000000000..cc245a9d0 --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-card/workflow-launcher-card.component.ts @@ -0,0 +1,21 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { IconComponent } from '@osf/shared/components/icon/icon.component'; + +import { WorkflowLauncherCard } from '../../models/workflow-launcher-card.model'; + +@Component({ + selector: 'osf-workflow-launcher-card', + imports: [Button, IconComponent, RouterLink, TranslatePipe], + templateUrl: './workflow-launcher-card.component.html', + styleUrl: './workflow-launcher-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkflowLauncherCardComponent { + card = input.required(); +} diff --git a/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.html b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.html new file mode 100644 index 000000000..a95699533 --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.html @@ -0,0 +1,15 @@ +
+
+

{{ 'home.loggedIn.dashboard.workflowLauncher.title' | translate }}

+

{{ 'home.loggedIn.dashboard.workflowLauncher.description1' | translate }}

+

+
+ +
+ @for (card of cards; track card.titleKey) { +
+ +
+ } +
+
diff --git a/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.scss b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.scss new file mode 100644 index 000000000..eebdccfad --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.scss @@ -0,0 +1,3 @@ +.workflow-launcher { + background-color: var(--white); +} diff --git a/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.spec.ts b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.spec.ts new file mode 100644 index 000000000..594d7aa72 --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.spec.ts @@ -0,0 +1,54 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { IconComponent } from '@osf/shared/components/icon/icon.component'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { WORKFLOW_LAUNCHER_CARDS } from '../../constants/workflow-launcher.constants'; + +import { WorkflowLauncherSectionComponent } from './workflow-launcher-section.component'; + +describe('WorkflowLauncherSectionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [WorkflowLauncherSectionComponent, ...MockComponents(IconComponent)], + providers: [provideOSFCore(), provideRouter([])], + }); + + fixture = TestBed.createComponent(WorkflowLauncherSectionComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should expose workflow launcher cards from constants', () => { + expect(fixture.componentInstance.cards).toEqual(WORKFLOW_LAUNCHER_CARDS); + }); + + it('should render the workflow launcher intro copy', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + + const intro = fixture.nativeElement.querySelector('section.workflow-launcher > .flex.flex-column'); + const heading = intro?.querySelector('h1'); + const descriptions = intro?.querySelectorAll('p'); + + expect(heading?.textContent?.trim()).toBe('home.loggedIn.dashboard.workflowLauncher.title'); + expect(descriptions?.length).toBe(2); + expect(descriptions?.[0]?.textContent?.trim()).toBe('home.loggedIn.dashboard.workflowLauncher.description1'); + expect(descriptions?.[1]?.textContent?.trim()).toBe('home.loggedIn.dashboard.workflowLauncher.description2'); + }); + + it('should render a card for each workflow launcher entry', () => { + const cards = fixture.nativeElement.querySelectorAll('osf-workflow-launcher-card'); + + expect(cards.length).toBe(WORKFLOW_LAUNCHER_CARDS.length); + }); +}); diff --git a/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.ts b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.ts new file mode 100644 index 000000000..8062383af --- /dev/null +++ b/src/app/features/home/components/workflow-launcher-section/workflow-launcher-section.component.ts @@ -0,0 +1,17 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { WORKFLOW_LAUNCHER_CARDS } from '../../constants/workflow-launcher.constants'; +import { WorkflowLauncherCardComponent } from '../workflow-launcher-card/workflow-launcher-card.component'; + +@Component({ + selector: 'osf-workflow-launcher-section', + imports: [WorkflowLauncherCardComponent, TranslatePipe], + templateUrl: './workflow-launcher-section.component.html', + styleUrl: './workflow-launcher-section.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkflowLauncherSectionComponent { + readonly cards = WORKFLOW_LAUNCHER_CARDS; +} diff --git a/src/app/features/home/constants/choose-repository.constants.ts b/src/app/features/home/constants/choose-repository.constants.ts new file mode 100644 index 000000000..0089f9ca5 --- /dev/null +++ b/src/app/features/home/constants/choose-repository.constants.ts @@ -0,0 +1,52 @@ +import { RepositoryOption } from '../models/choose-repository.model'; + +export const REPOSITORY_OPTIONS: RepositoryOption[] = [ + { + id: 'zenodo', + titleKey: 'chooseRepository.repositories.zenodo.title', + logoUrl: 'assets/images/repositories/zenodo_logo.png', + descriptionKey: 'chooseRepository.repositories.zenodo.description', + linkTextKey: 'chooseRepository.repositories.zenodo.linkText', + linkUrl: 'https://zenodo.org/', + }, + { + id: 'dryad', + titleKey: 'chooseRepository.repositories.dryad.title', + logoUrl: 'assets/images/repositories/dryad_logo.png', + descriptionKey: 'chooseRepository.repositories.dryad.description', + linkTextKey: 'chooseRepository.repositories.dryad.linkText', + linkUrl: 'https://datadryad.org/', + }, + { + id: 'figshare', + titleKey: 'chooseRepository.repositories.figshare.title', + logoUrl: 'assets/images/repositories/figshare_logo.png', + descriptionKey: 'chooseRepository.repositories.figshare.description', + linkTextKey: 'chooseRepository.repositories.figshare.linkText', + linkUrl: 'https://figshare.com/', + }, + { + id: 'harvardDataverse', + titleKey: 'chooseRepository.repositories.harvardDataverse.title', + logoUrl: 'assets/images/repositories/dataverse_logo.png', + descriptionKey: 'chooseRepository.repositories.harvardDataverse.description', + linkTextKey: 'chooseRepository.repositories.harvardDataverse.linkText', + linkUrl: 'https://dataverse.harvard.edu/', + }, + { + id: 'mendeleyData', + titleKey: 'chooseRepository.repositories.mendeleyData.title', + logoUrl: 'assets/images/repositories/mendeley_logo.png', + descriptionKey: 'chooseRepository.repositories.mendeleyData.description', + linkTextKey: 'chooseRepository.repositories.mendeleyData.linkText', + linkUrl: 'https://data.mendeley.com/', + }, + { + id: 'vivli', + titleKey: 'chooseRepository.repositories.vivli.title', + logoUrl: 'assets/images/repositories/vivli_logo.png', + descriptionKey: 'chooseRepository.repositories.vivli.description', + linkTextKey: 'chooseRepository.repositories.vivli.linkText', + linkUrl: 'https://vivli.org/', + }, +]; diff --git a/src/app/features/home/constants/dashboard-products.constants.ts b/src/app/features/home/constants/dashboard-products.constants.ts new file mode 100644 index 000000000..75ddab13e --- /dev/null +++ b/src/app/features/home/constants/dashboard-products.constants.ts @@ -0,0 +1,26 @@ +import { DashboardProductLink } from '../models/dashboard-product-link.model'; + +export const DASHBOARD_PRODUCT_LINKS: DashboardProductLink[] = [ + { + link: 'https://www.cos.io/products/osf-collections', + imageSrc: 'assets/images/dashboard/products/osf-collections.png', + altKey: 'home.loggedIn.dashboard.images.osfCollectionsImageAltText', + external: true, + testId: 'products-collections', + }, + { + link: '/institutions', + imageSrc: 'assets/images/dashboard/products/osf-institutions.png', + altKey: 'home.loggedIn.dashboard.images.osfInstitutionsImageAltText', + }, + { + link: '/registries', + imageSrc: 'assets/images/dashboard/products/osf-registries.png', + altKey: 'home.loggedIn.dashboard.images.osfRegistriesImageAltTest', + }, + { + link: '/preprints', + imageSrc: 'assets/images/dashboard/products/osf-preprints.png', + altKey: 'home.loggedIn.dashboard.images.osfPreprintsImageAltTest', + }, +]; diff --git a/src/app/features/home/constants/index.ts b/src/app/features/home/constants/index.ts deleted file mode 100644 index 370767922..000000000 --- a/src/app/features/home/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './data'; diff --git a/src/app/features/home/constants/workflow-launcher.constants.ts b/src/app/features/home/constants/workflow-launcher.constants.ts new file mode 100644 index 000000000..c8dde8f19 --- /dev/null +++ b/src/app/features/home/constants/workflow-launcher.constants.ts @@ -0,0 +1,36 @@ +import { WorkflowLauncherCard, WorkflowLauncherCardTheme } from '../models/workflow-launcher-card.model'; + +export const WORKFLOW_LAUNCHER_CARDS: WorkflowLauncherCard[] = [ + { + iconClass: 'custom-icon-registries', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.studyPlans.button', + theme: WorkflowLauncherCardTheme.Blue, + routerLink: '/registries/osf/new', + }, + { + iconClass: 'custom-icon-projects', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.researchMaterials.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.researchMaterials.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.researchMaterials.button', + theme: WorkflowLauncherCardTheme.Teal, + routerLink: '/choose-repository', + }, + { + iconClass: 'custom-icon-preprints', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.preprints.button', + theme: WorkflowLauncherCardTheme.TealGreen, + routerLink: '/preprints/select', + }, + { + iconClass: 'fas fa-magnifying-glass', + titleKey: 'home.loggedIn.dashboard.workflowLauncher.cards.search.title', + descriptionKey: 'home.loggedIn.dashboard.workflowLauncher.cards.search.description', + buttonLabelKey: 'home.loggedIn.dashboard.workflowLauncher.cards.search.button', + theme: WorkflowLauncherCardTheme.Green, + routerLink: '/search', + }, +]; diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index 891e13488..fbd6f7e1b 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -11,7 +11,7 @@ import { Router, RouterLink } from '@angular/router'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { INTEGRATION_ICONS, SLIDES } from './constants'; +import { INTEGRATION_ICONS, SLIDES } from './constants/data'; @Component({ selector: 'osf-home', diff --git a/src/app/features/home/models/choose-repository.model.ts b/src/app/features/home/models/choose-repository.model.ts new file mode 100644 index 000000000..fd058ff22 --- /dev/null +++ b/src/app/features/home/models/choose-repository.model.ts @@ -0,0 +1,8 @@ +export interface RepositoryOption { + id: string; + titleKey: string; + logoUrl: string; + descriptionKey: string; + linkTextKey: string; + linkUrl: string; +} diff --git a/src/app/features/home/models/dashboard-product-link.model.ts b/src/app/features/home/models/dashboard-product-link.model.ts new file mode 100644 index 000000000..3f40c022c --- /dev/null +++ b/src/app/features/home/models/dashboard-product-link.model.ts @@ -0,0 +1,7 @@ +export interface DashboardProductLink { + imageSrc: string; + altKey: string; + link: string; + external?: boolean; + testId?: string; +} diff --git a/src/app/features/home/models/workflow-launcher-card.model.ts b/src/app/features/home/models/workflow-launcher-card.model.ts new file mode 100644 index 000000000..15232abc5 --- /dev/null +++ b/src/app/features/home/models/workflow-launcher-card.model.ts @@ -0,0 +1,15 @@ +export enum WorkflowLauncherCardTheme { + Blue = 'blue', + Teal = 'teal', + TealGreen = 'teal-green', + Green = 'green', +} + +export interface WorkflowLauncherCard { + iconClass: string; + titleKey: string; + descriptionKey: string; + buttonLabelKey: string; + theme: WorkflowLauncherCardTheme; + routerLink: string; +} diff --git a/src/app/features/home/pages/choose-repository/choose-repository.component.html b/src/app/features/home/pages/choose-repository/choose-repository.component.html new file mode 100644 index 000000000..b5e49d9ad --- /dev/null +++ b/src/app/features/home/pages/choose-repository/choose-repository.component.html @@ -0,0 +1,30 @@ +
+ + +
+

+ +
+ @for (repository of repositories; track repository.id) { +
+ +
+ } +
+
+
diff --git a/src/app/features/home/pages/choose-repository/choose-repository.component.scss b/src/app/features/home/pages/choose-repository/choose-repository.component.scss new file mode 100644 index 000000000..9fa9548d8 --- /dev/null +++ b/src/app/features/home/pages/choose-repository/choose-repository.component.scss @@ -0,0 +1,26 @@ +:host { + flex: 1; +} + +.container { + background-color: var(--white); +} + +.repository-card { + background-color: var(--white); + border: 1px solid var(--grey-2); + border-radius: 0.5rem; +} + +.repository-card-logo { + width: 7.5rem; + height: 7.5rem; + object-fit: contain; +} + +.repository-card-link { + --p-button-outlined-primary-color: var(--dark-blue-1); + --p-button-outlined-primary-border-color: var(--grey-2); + --p-button-outlined-primary-hover-background: var(--bg-blue-2); + --p-button-outlined-primary-active-background: var(--bg-blue-2); +} diff --git a/src/app/features/home/pages/choose-repository/choose-repository.component.spec.ts b/src/app/features/home/pages/choose-repository/choose-repository.component.spec.ts new file mode 100644 index 000000000..5e2279383 --- /dev/null +++ b/src/app/features/home/pages/choose-repository/choose-repository.component.spec.ts @@ -0,0 +1,52 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { REPOSITORY_OPTIONS } from '../../constants/choose-repository.constants'; + +import { ChooseRepositoryComponent } from './choose-repository.component'; + +describe('ChooseRepositoryComponent', () => { + let fixture: ComponentFixture; + let translateService: TranslateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ChooseRepositoryComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(ChooseRepositoryComponent); + translateService = TestBed.inject(TranslateService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should render the page title', () => { + const title = fixture.debugElement.query(By.css('h1')); + expect(title.nativeElement.textContent).toContain(translateService.instant('chooseRepository.title')); + }); + + it('should render a card for each repository', () => { + const cards = fixture.debugElement.queryAll(By.css('.repository-card')); + expect(cards.length).toBe(REPOSITORY_OPTIONS.length); + }); + + it('should link each repository button to its home page', () => { + const links = fixture.debugElement.queryAll(By.css('.repository-card-link')); + + links.forEach((link, index) => { + const repository = REPOSITORY_OPTIONS[index]; + expect(link.nativeElement.href).toBe(repository.linkUrl); + expect(link.nativeElement.textContent.trim()).toBe(translateService.instant(repository.linkTextKey)); + expect(link.nativeElement.target).toBe('_blank'); + expect(link.nativeElement.rel).toBe('noopener noreferrer'); + }); + }); +}); diff --git a/src/app/features/home/pages/choose-repository/choose-repository.component.ts b/src/app/features/home/pages/choose-repository/choose-repository.component.ts new file mode 100644 index 000000000..bf7c1ad58 --- /dev/null +++ b/src/app/features/home/pages/choose-repository/choose-repository.component.ts @@ -0,0 +1,18 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; + +import { REPOSITORY_OPTIONS } from '../../constants/choose-repository.constants'; + +@Component({ + selector: 'osf-choose-repository', + imports: [SubHeaderComponent, TranslatePipe], + templateUrl: './choose-repository.component.html', + styleUrl: './choose-repository.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChooseRepositoryComponent { + readonly repositories = REPOSITORY_OPTIONS; +} diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 059bf4be3..a1c71b4eb 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -2,75 +2,73 @@ @if (areProjectsLoading()) { } @else { + + + @if (isWorkflowLauncherEnabled()) { + + } + @if (existsProjects()) { - -
-
-

- {{ 'home.loggedIn.dashboard.quickSearch.goTo' | translate }} - - {{ 'home.loggedIn.dashboard.quickSearch.myProjects' | translate }} - - {{ 'home.loggedIn.dashboard.quickSearch.toOrganize' | translate }} - - {{ 'home.loggedIn.dashboard.quickSearch.search' | translate }} - - {{ 'home.loggedIn.dashboard.quickSearch.osf' | translate }} -

- +
+

+ {{ 'home.loggedIn.dashboard.quickSearch.goTo' | translate }} + + {{ 'home.loggedIn.dashboard.quickSearch.myProjects' | translate }} + + {{ 'home.loggedIn.dashboard.quickSearch.toOrganize' | translate }} + + {{ 'home.loggedIn.dashboard.quickSearch.search' | translate }} + + {{ 'home.loggedIn.dashboard.quickSearch.osf' | translate }} +

- -
+ -
- -

{{ 'home.loggedIn.publicProjects.title' | translate }}

-
+ +
-
-
-

{{ 'home.loggedIn.latestResearch.title' | translate }}

-

{{ 'home.loggedIn.latestResearch.subtitle' | translate }}

-
+
+ +

{{ 'home.loggedIn.publicProjects.title' | translate }}

+
- +
+
+

{{ 'home.loggedIn.latestResearch.title' | translate }}

+

{{ 'home.loggedIn.latestResearch.subtitle' | translate }}

+ +
} @else { -

{{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}

@@ -89,7 +87,7 @@

{{ 'home.loggedIn.latestResearch.title' | translate }}

>
-
+
{{ 'home.loggedIn.latestResearch.title' | translate }}
} -
- - - - - - - - - - - - - - +
+ @for (product of dashboardProducts; track product.imageSrc) { + @if (product.external) { + + + + } @else { + + + + } + }
} diff --git a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts index 9a8d9d274..cccfa4e26 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -11,13 +11,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { UserSelectors } from '@osf/core/store/user'; import { CreateProjectDialogComponent } from '@osf/features/my-projects/components/create-project-dialog/create-project-dialog.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; @@ -33,6 +36,9 @@ import { SignalOverride, } from '@testing/providers/store-provider.mock'; +import { WorkflowLauncherSectionComponent } from '../../components/workflow-launcher-section/workflow-launcher-section.component'; +import { DASHBOARD_PRODUCT_LINKS } from '../../constants/dashboard-products.constants'; + import { DashboardComponent } from './dashboard.component'; describe('DashboardComponent', () => { @@ -43,10 +49,31 @@ describe('DashboardComponent', () => { let customDialogService: CustomDialogServiceMockType; let projectRedirectDialogService: { showProjectRedirectDialog: Mock }; + const projectItem: MyResourcesItem = { + id: '1', + type: 'nodes', + title: 'Alpha project', + dateCreated: '2024-01-01', + dateModified: '2024-01-02', + isPublic: true, + contributors: [], + }; + + const secondProjectItem: MyResourcesItem = { + id: '2', + type: 'nodes', + title: 'Beta project', + dateCreated: '2024-01-03', + dateModified: '2024-01-04', + isPublic: false, + contributors: [], + }; + const defaultSignals: SignalOverride[] = [ { selector: MyResourcesSelectors.getProjects, value: [] }, { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, { selector: MyResourcesSelectors.getProjectsLoading, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, ]; interface SetupOverrides extends BaseSetupOverrides { @@ -71,7 +98,8 @@ describe('DashboardComponent', () => { SearchInputComponent, IconComponent, LoadingSpinnerComponent, - ScheduledBannerComponent + ScheduledBannerComponent, + WorkflowLauncherSectionComponent ), ], providers: [ @@ -91,9 +119,18 @@ describe('DashboardComponent', () => { fixture.detectChanges(); } - it('should create', () => { + it('should create and fetch projects with default params on init', () => { setup(); + expect(component).toBeTruthy(); + expect(store.dispatch).toHaveBeenCalledWith( + new GetMyProjects(1, 10, { + searchValue: '', + searchFields: ['title'], + sortColumn: undefined, + sortOrder: SortOrder.Asc, + }) + ); }); it('should read query params and fetch projects on init', () => { @@ -122,6 +159,32 @@ describe('DashboardComponent', () => { ); }); + it('should sync total projects count into table params', () => { + setup({ selectorOverrides: [{ selector: MyResourcesSelectors.getTotalProjects, value: 42 }] }); + + expect(component.tableParams().totalRecords).toBe(42); + }); + + it('should default sort order when query param sort order is invalid', () => { + setup({ + routeQueryParams: { + sortField: 'title', + sortOrder: 'invalid', + }, + }); + + expect(component.sortOrder()).toBe(SortOrder.Asc); + }); + + it('should use dashboard sub header when projects exist', () => { + setup({ + selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem] }], + }); + + expect(component.existsProjects()).toBeTruthy(); + expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title'); + }); + it('should update query params on page change', () => { setup(); (routerMock.navigate as Mock).mockClear(); @@ -162,29 +225,98 @@ describe('DashboardComponent', () => { }); }); - it('should create filters from current search and sort state', () => { + it('should not update query params on sort when field is missing', () => { + setup(); + (routerMock.navigate as Mock).mockClear(); + + component.onSort({ field: undefined, order: SortOrder.Desc } as never); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should dispatch fetch projects with current search and sort state', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.searchControl.setValue('alp'); + component.sortColumn.set('title'); + component.sortOrder.set(-1); + + component.fetchProjects(); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetMyProjects(1, 10, { + searchValue: 'alp', + searchFields: ['title'], + sortColumn: 'title', + sortOrder: -1, + }) + ); + }); + + it('should filter projects by search value', () => { setup({ + routeQueryParams: { search: 'alp' }, selectorOverrides: [ { selector: MyResourcesSelectors.getProjects, - value: [ - { id: '1', title: 'Alpha project' }, - { id: '2', title: 'Beta project' }, - ], + value: [projectItem, secondProjectItem], }, ], }); - component.searchControl.setValue('alp'); - component.sortColumn.set('title'); - component.sortOrder.set(-1); + expect(component.filteredProjects()).toEqual([projectItem]); + }); + + it('should treat search value as existing projects when list is empty', () => { + setup({ routeQueryParams: { search: 'query' } }); + + expect(component.existsProjects()).toBeTruthy(); + expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title'); + }); + + it('should use welcome sub header when no projects and no search', () => { + setup(); + + expect(component.existsProjects()).toBe(false); + expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.welcome'); + }); - expect(component.createFilters()).toEqual({ - searchValue: 'alp', - searchFields: ['title'], - sortColumn: 'title', - sortOrder: -1, + it('should expose dashboard product links', () => { + setup(); + + expect(component.dashboardProducts).toBe(DASHBOARD_PRODUCT_LINKS); + }); + + it('should update query params after search debounce', () => { + vi.useFakeTimers(); + setup(); + (routerMock.navigate as Mock).mockClear(); + + component.searchControl.setValue('alpha'); + vi.advanceTimersByTime(300); + + expect(routerMock.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), + queryParams: { + page: 1, + rows: 10, + search: 'alpha', + sortField: undefined, + sortOrder: 1, + }, + queryParamsHandling: 'merge', }); + vi.useRealTimers(); + }); + + it('should navigate to project and set active project', () => { + setup(); + + component.navigateToProject(projectItem); + + expect(component.activeProject()).toEqual(projectItem); + expect(routerMock.navigate).toHaveBeenCalledWith([projectItem.id]); }); it('should open create project dialog and redirect on close result', () => { @@ -202,6 +334,17 @@ describe('DashboardComponent', () => { expect(projectRedirectDialogService.showProjectRedirectDialog).toHaveBeenCalledWith('p1'); }); + it('should not redirect when create project dialog closes without project id', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable())); + + component.createProject(); + onClose$.next(null); + + expect(projectRedirectDialogService.showProjectRedirectDialog).not.toHaveBeenCalled(); + }); + it('should open help link in new tab', () => { setup(); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); @@ -219,4 +362,113 @@ describe('DashboardComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new ClearMyResources()); }); + + it('should not clear my resources on destroy on server', () => { + setup({ platformId: 'server' }); + (store.dispatch as Mock).mockClear(); + + fixture.destroy(); + + expect(store.dispatch).not.toHaveBeenCalledWith(new ClearMyResources()); + }); + + it('should show loading spinner while projects are loading', () => { + setup({ selectorOverrides: [{ selector: MyResourcesSelectors.getProjectsLoading, value: true }] }); + + expect(fixture.nativeElement.querySelector('osf-loading-spinner')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('osf-sub-header')).toBeFalsy(); + }); + + it('should enable workflow launcher when feature flag is active', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [FEATURE_FLAGS.WORKFLOW_LAUNCHER] }], + }); + + expect(component.isWorkflowLauncherEnabled()).toBe(true); + expect(fixture.nativeElement.querySelector('osf-workflow-launcher-section')).toBeTruthy(); + }); + + it('should hide workflow launcher when feature flag is inactive', () => { + setup(); + + expect(component.isWorkflowLauncherEnabled()).toBe(false); + expect(fixture.nativeElement.querySelector('osf-workflow-launcher-section')).toBeFalsy(); + }); + + it('should return all projects when search is empty', () => { + setup({ + selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem, secondProjectItem] }], + }); + + expect(component.filteredProjects()).toEqual([projectItem, secondProjectItem]); + }); + + it('should filter projects case insensitively', () => { + setup({ + routeQueryParams: { search: 'ALPHA' }, + selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem, secondProjectItem] }], + }); + + expect(component.filteredProjects()).toEqual([projectItem]); + }); + + it('should omit empty search from query params', () => { + setup(); + (routerMock.navigate as Mock).mockClear(); + + component.searchControl.setValue(''); + component.updateQueryParams(); + + expect(routerMock.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), + queryParams: { + page: 1, + rows: 10, + search: undefined, + sortField: undefined, + sortOrder: 1, + }, + queryParamsHandling: 'merge', + }); + }); + + it('should not update query params when debounced search value is unchanged', () => { + vi.useFakeTimers(); + setup(); + component.searchControl.setValue('alpha'); + vi.advanceTimersByTime(300); + (routerMock.navigate as Mock).mockClear(); + + component.searchControl.setValue('alpha'); + vi.advanceTimersByTime(300); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should render dashboard product links', () => { + setup(); + + expect(fixture.nativeElement.querySelectorAll('img').length).toBe(DASHBOARD_PRODUCT_LINKS.length); + }); + + it('should render external product link with blank target', () => { + setup(); + + const externalLink = fixture.nativeElement.querySelector('[data-test-products-collections]'); + + expect(externalLink?.getAttribute('target')).toBe('_blank'); + expect(externalLink?.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should not redirect when create project dialog closes with missing project id', () => { + setup(); + const onClose$ = new Subject<{ project: { id?: string } }>(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable())); + + component.createProject(); + onClose$.next({ project: {} }); + + expect(projectRedirectDialogService.showProjectRedirectDialog).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 63ec0f106..4ac70be7c 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -14,7 +14,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { ScheduledBannerComponent } from '@osf/core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { UserSelectors } from '@osf/core/store/user'; import { CreateProjectDialogComponent } from '@osf/features/my-projects/components/create-project-dialog/create-project-dialog.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -22,13 +23,17 @@ import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-tab import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model'; +import { TableParameters } from '@osf/shared/models/table-parameters.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; -import { TableParameters } from '@shared/models/table-parameters.model'; + +import { WorkflowLauncherSectionComponent } from '../../components/workflow-launcher-section/workflow-launcher-section.component'; +import { DASHBOARD_PRODUCT_LINKS } from '../../constants/dashboard-products.constants'; @Component({ selector: 'osf-dashboard', @@ -42,6 +47,7 @@ import { TableParameters } from '@shared/models/table-parameters.model'; TranslatePipe, LoadingSpinnerComponent, ScheduledBannerComponent, + WorkflowLauncherSectionComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.scss', @@ -64,15 +70,23 @@ export class DashboardComponent implements OnInit { readonly projects = select(MyResourcesSelectors.getProjects); readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); readonly areProjectsLoading = select(MyResourcesSelectors.getProjectsLoading); + readonly activeFlags = select(UserSelectors.getActiveFlags); readonly actions = createDispatchMap({ getMyProjects: GetMyProjects, clearMyResources: ClearMyResources }); + readonly dashboardProducts = DASHBOARD_PRODUCT_LINKS; + + readonly isWorkflowLauncherEnabled = computed(() => this.activeFlags().includes(FEATURE_FLAGS.WORKFLOW_LAUNCHER)); + readonly filteredProjects = computed(() => { const search = this.searchControl.value?.toLowerCase() ?? ''; return this.projects().filter((project) => project.title.toLowerCase().includes(search)); }); readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length); + readonly subHeaderTitle = computed(() => + this.existsProjects() ? 'home.loggedIn.dashboard.title' : 'home.loggedIn.dashboard.welcome' + ); constructor() { this.setupSearchSubscription(); @@ -136,18 +150,15 @@ export class DashboardComponent implements OnInit { } fetchProjects(): void { - const filters = this.createFilters(); - const page = Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) + 1; - this.actions.getMyProjects(page, this.tableParams().rows, filters); - } - - createFilters(): MyResourcesSearchFilters { - return { + const filters: MyResourcesSearchFilters = { searchValue: this.searchControl.value ?? '', searchFields: ['title'], sortColumn: this.sortColumn(), sortOrder: this.sortOrder(), }; + + const page = Math.floor(this.tableParams().firstRowIndex / this.tableParams().rows) + 1; + this.actions.getMyProjects(page, this.tableParams().rows, filters); } updateQueryParams(isPageReset = false): void { diff --git a/src/app/features/home/pages/index.ts b/src/app/features/home/pages/index.ts deleted file mode 100644 index 848f24947..000000000 --- a/src/app/features/home/pages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardComponent } from './dashboard/dashboard.component'; diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.html b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html index a1d7fd280..edc495ac4 100644 --- a/src/app/features/institutions/pages/institutions-list/institutions-list.component.html +++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.html @@ -3,7 +3,7 @@ class="pt-6" [description]="'institutions.description' | translate" [title]="'institutions.title' | translate" - [icon]="'custom-icon-institutions-dark'" + [icon]="'custom-icon-institutions'" /> diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html index a8c947d57..4066d7384 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html @@ -1,6 +1,6 @@ diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html index 12fca8ad0..fcb4ca2c3 100644 --- a/src/app/features/my-projects/my-projects.component.html +++ b/src/app/features/my-projects/my-projects.component.html @@ -3,7 +3,7 @@ [showButton]="true" [buttonLabel]="'myProjects.header.createProject' | translate" [title]="'myProjects.header.title' | translate" - [icon]="'custom-icon-projects-dark'" + [icon]="'custom-icon-projects'" (buttonClick)="createProject()" /> diff --git a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.html b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.html index 5b4e03419..39d967301 100644 --- a/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.html +++ b/src/app/features/preprints/components/preprint-provider-footer/preprint-provider-footer.component.html @@ -1,6 +1,6 @@ @if (footerHtml()) { } diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html index b0f7b6659..67234dbe3 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html @@ -1,7 +1,7 @@
- +

{{ 'preprints.title' | translate }}

@if (isProviderLoading()) { @@ -25,7 +25,7 @@

{{ 'preprints.title' | translate }}

} @else { -
+
} {{ 'preprints.poweredBy' | translate }}
diff --git a/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss index 885733499..7877c3a3e 100644 --- a/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.scss @@ -1,17 +1,13 @@ .subheader { color: var(--dark-blue-1); - .custom-icon-preprints-dark { + .custom-icon-preprints { width: 2.25rem; height: 2.25rem; } .provider-description { line-height: 1.5rem; - - a { - font-weight: bold; - } } } diff --git a/src/app/features/project/project-addons/project-addons.component.ts b/src/app/features/project/project-addons/project-addons.component.ts index 100cbfb15..ec39264e3 100644 --- a/src/app/features/project/project-addons/project-addons.component.ts +++ b/src/app/features/project/project-addons/project-addons.component.ts @@ -32,6 +32,7 @@ import { SelectComponent } from '@osf/shared/components/select/select.component' import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ADDON_CATEGORY_OPTIONS } from '@osf/shared/constants/addons-category-options.const'; import { ADDON_TAB_OPTIONS } from '@osf/shared/constants/addons-tab-options.const'; +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; import { AddonTabValue } from '@osf/shared/enums/addon-tab.enum'; import { AddonCategory } from '@osf/shared/enums/addons-category.enum'; import { createAddonCardModel, sortAddonCardsAlphabetically } from '@osf/shared/helpers/addon-card.helper'; @@ -114,7 +115,7 @@ export class ProjectAddonsComponent implements OnInit { activeFlags = select(UserSelectors.getActiveFlags); readonly categoryOptions = computed(() => { - if (this.activeFlags().includes('gravy_redirect')) { + if (this.activeFlags().includes(FEATURE_FLAGS.GRAVY_REDIRECT)) { return [ ...ADDON_CATEGORY_OPTIONS, { diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index e8f7a46ba..133d7c043 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -31,7 +31,7 @@
} @else { -
+
}
diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index b389c5af8..28d3d9893 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -2,7 +2,7 @@
{ - if (this.activeFlags().includes('gravy_redirect')) { + if (this.activeFlags().includes(FEATURE_FLAGS.GRAVY_REDIRECT)) { return [ ...ADDON_CATEGORY_OPTIONS, { diff --git a/src/app/shared/components/recent-activity/recent-activity-list.component.html b/src/app/shared/components/recent-activity/recent-activity-list.component.html index 8ec859417..555813010 100644 --- a/src/app/shared/components/recent-activity/recent-activity-list.component.html +++ b/src/app/shared/components/recent-activity/recent-activity-list.component.html @@ -2,7 +2,7 @@
@for (activityLog of activityLogs(); track activityLog.id) {
https://www.re3data.org/.", + "learnMore": "Learn more", + "repositories": { + "dryad": { + "title": "Dryad", + "description": "Focused on research data underlying published findings. Best suited for datasets that accompany a journal article. Includes built-in curation support to help meet sharing standards.", + "linkText": "Go to Dryad" + }, + "figshare": { + "title": "Figshare", + "description": "Accepts datasets, code, images, presentations, and more from any discipline, and supports sharing multiple output types from a single project.", + "linkText": "Go to Figshare" + }, + "harvardDataverse": { + "title": "Harvard Dataverse", + "description": "A robust repository for research data across all disciplines, hosted across multiple institutions globally. Widely recognized by journals and funders.", + "linkText": "Go to Dataverse" + }, + "mendeleyData": { + "title": "Mendeley Data", + "description": "Mendeley Data is a free cloud-based collaborative repository for data storage. A good option for researchers already using Mendeley or working within the Elsevier ecosystem.", + "linkText": "Go to Mendeley" + }, + "vivli": { + "title": "Vivli", + "description": "Specialized for clinical and biomedical research, particularly individual participant-level data from clinical trials. Best suited for health sciences researchers with funder or journal data sharing requirements.", + "linkText": "Go to Vivli" + }, + "zenodo": { + "title": "Zenodo (recommended)", + "description": "A free, general-purpose repository hosted by CERN for researchers. Accepts any research output type and assigns a permanent DOI to every object.", + "linkText": "Go to Zenodo" + } + } + }, "common": { "accessibility": { "confirmation": "Confirmation", @@ -757,6 +794,33 @@ "osfPreprintsImageAltTest": "OSF Preprints", "osfRegistriesImageAltTest": "OSF Registries" }, + "workflowLauncher": { + "title": "Plan and document the lifecycle of your research", + "description1": "OSF is designed to support your research process — planning your study, registering your methods, and sharing your findings as preprints. ", + "description2": "Clarify your plan, connect all data and materials that resulted from the study, and then post your preprint. Open scholarship enables more discoverability, increased transparency, and ensures research rigor. (Learn more about the OSF Project transition).", + "cards": { + "studyPlans": { + "title": "Study Plans", + "description": "Document your research questions, methods, and analysis plans before you analyze data.", + "button": "Add Study Plan" + }, + "researchMaterials": { + "title": "Research Materials", + "description": "Store and share datasets, code, materials, and other outputs from your research in a trusted public repository.", + "button": "Store Research Materials" + }, + "preprints": { + "title": "Preprints", + "description": "Publish the outcomes of your research with an open access preprint, and connect the preprint to your study plan.", + "button": "Add Preprint" + }, + "search": { + "title": "Search", + "description": "Search across study plans and preprints with enhanced metadata and filters.", + "button": "Go to Search" + } + } + }, "noCreatedProject": "You haven’t created a project yet. Click the \"Create New Project\" button above to get started.", "quickSearch": { "goTo": "Go to", @@ -802,19 +866,19 @@ "support": { "sections": { "collect": { - "description": "Store data, code, and other materials in OSF Storage, or connect your Dropbox or other third-party account. Every file gets a unique, persistent URL for citing and sharing", + "description": "Store your datasets, code, and research materials in trusted repositories then link them to your registration to keep them findable, citable, and preserved for the long term.", "title": "Collect and Analyze Data" }, "design": { - "description": "Start a project and add collaborators, giving them access to protocols and other research materials. Built-in version control tracks the evolution of your study", + "description": "Register your study design and invite collaborators to review your protocols and research materials before data collection begins.", "title": "Design Your Study" }, "publish": { - "description": "Share papers in OSF Preprints or a community-based preprint provider, so others can find and cite your work. Track impact with metrics like downloads and view counts", + "description": "Share papers in OSF Preprints or a community-based preprint provider, so others can find and cite your work. Track impact with metrics like downloads and view counts.", "title": "Publish Your Reports" }, "search": { - "description": "Find papers, data, and materials to inspire your next research project. Search public projects to build on the work of others and find new collaborators", + "description": "Find papers, data, and materials to inspire your next research project. Search public registrations and preprints to build on the work of others and find new collaborators.", "title": "Search And Discover" } }, diff --git a/src/assets/icons/colored/collections-dark.svg b/src/assets/icons/colored/collections-dark.svg deleted file mode 100644 index 1e697cbd9..000000000 --- a/src/assets/icons/colored/collections-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/icons/colored/collections.svg b/src/assets/icons/colored/collections.svg index fd98f14a2..b7a4f0fb3 100644 --- a/src/assets/icons/colored/collections.svg +++ b/src/assets/icons/colored/collections.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/colored/institutions-dark.svg b/src/assets/icons/colored/institutions-dark.svg deleted file mode 100644 index 78e1fb33c..000000000 --- a/src/assets/icons/colored/institutions-dark.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/icons/colored/institutions.svg b/src/assets/icons/colored/institutions.svg index 6b1288a06..5981e0e62 100644 --- a/src/assets/icons/colored/institutions.svg +++ b/src/assets/icons/colored/institutions.svg @@ -1,20 +1,20 @@ - - - - - - - - - - - + + + + + + + + + + + - + diff --git a/src/assets/icons/colored/meetings-dark.svg b/src/assets/icons/colored/meetings-dark.svg deleted file mode 100644 index a27f4157a..000000000 --- a/src/assets/icons/colored/meetings-dark.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/assets/icons/colored/meetings.svg b/src/assets/icons/colored/meetings.svg index 08247183b..53e2fe927 100644 --- a/src/assets/icons/colored/meetings.svg +++ b/src/assets/icons/colored/meetings.svg @@ -1,18 +1,18 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/src/assets/icons/colored/preprints-dark.svg b/src/assets/icons/colored/preprints-dark.svg deleted file mode 100644 index fe5908a69..000000000 --- a/src/assets/icons/colored/preprints-dark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/assets/icons/colored/preprints.svg b/src/assets/icons/colored/preprints.svg index 7573b0285..71f5eb180 100644 --- a/src/assets/icons/colored/preprints.svg +++ b/src/assets/icons/colored/preprints.svg @@ -1,7 +1,7 @@ - - + + diff --git a/src/assets/icons/colored/projects-dark.svg b/src/assets/icons/colored/projects-dark.svg deleted file mode 100644 index a821a23e6..000000000 --- a/src/assets/icons/colored/projects-dark.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/assets/icons/colored/projects.svg b/src/assets/icons/colored/projects.svg index 26ef2bc17..6b8485219 100644 --- a/src/assets/icons/colored/projects.svg +++ b/src/assets/icons/colored/projects.svg @@ -1,12 +1,12 @@ - - - + + + - + diff --git a/src/assets/icons/colored/registries-dark.svg b/src/assets/icons/colored/registries-dark.svg deleted file mode 100644 index a2a34f6f6..000000000 --- a/src/assets/icons/colored/registries-dark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/assets/icons/colored/registries.svg b/src/assets/icons/colored/registries.svg index e2a621fac..941d98cb6 100644 --- a/src/assets/icons/colored/registries.svg +++ b/src/assets/icons/colored/registries.svg @@ -1,11 +1,11 @@ - - + + - + diff --git a/src/assets/images/repositories/dataverse_logo.png b/src/assets/images/repositories/dataverse_logo.png new file mode 100644 index 000000000..8ca654f4b Binary files /dev/null and b/src/assets/images/repositories/dataverse_logo.png differ diff --git a/src/assets/images/repositories/dryad_logo.png b/src/assets/images/repositories/dryad_logo.png new file mode 100644 index 000000000..d2a92f27a Binary files /dev/null and b/src/assets/images/repositories/dryad_logo.png differ diff --git a/src/assets/images/repositories/figshare_logo.png b/src/assets/images/repositories/figshare_logo.png new file mode 100644 index 000000000..99c9734d3 Binary files /dev/null and b/src/assets/images/repositories/figshare_logo.png differ diff --git a/src/assets/images/repositories/mendeley_logo.png b/src/assets/images/repositories/mendeley_logo.png new file mode 100644 index 000000000..6302b251a Binary files /dev/null and b/src/assets/images/repositories/mendeley_logo.png differ diff --git a/src/assets/images/repositories/vivli_logo.png b/src/assets/images/repositories/vivli_logo.png new file mode 100644 index 000000000..f1e5811f6 Binary files /dev/null and b/src/assets/images/repositories/vivli_logo.png differ diff --git a/src/assets/images/repositories/zenodo_logo.png b/src/assets/images/repositories/zenodo_logo.png new file mode 100644 index 000000000..d84adb8ed Binary files /dev/null and b/src/assets/images/repositories/zenodo_logo.png differ diff --git a/src/styles/_common.scss b/src/styles/_common.scss index c63894938..e70feab27 100644 --- a/src/styles/_common.scss +++ b/src/styles/_common.scss @@ -154,3 +154,7 @@ color: var(--branding-primary-color); } } + +.rich-text a { + font-weight: bold; +} diff --git a/src/styles/_icons.scss b/src/styles/_icons.scss index 6689d4ec5..56f00e6dd 100644 --- a/src/styles/_icons.scss +++ b/src/styles/_icons.scss @@ -1,85 +1,34 @@ -.custom-icon-registries { - background: url("/assets/icons/colored/registries.svg") no-repeat center; - background-size: contain; +@mixin custom-icon-mask($url) { + display: inline-block; width: 1rem; height: 1rem; + background-color: currentColor; + mask: url($url) no-repeat center / contain; + -webkit-mask: url($url) no-repeat center / contain; +} + +.custom-icon-registries { + @include custom-icon-mask("/assets/icons/colored/registries.svg"); } .custom-icon-preprints { - background: url("/assets/icons/colored/preprints.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/preprints.svg"); } .custom-icon-collections { - background: url("/assets/icons/colored/collections.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/collections.svg"); } .custom-icon-institutions { - background: url("/assets/icons/colored/institutions.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/institutions.svg"); } .custom-icon-projects { - background: url("/assets/icons/colored/projects.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/projects.svg"); } .custom-icon-meetings { - background: url("/assets/icons/colored/meetings.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-registries-dark { - background: url("/assets/icons/colored/registries-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-preprints-dark { - background: url("/assets/icons/colored/preprints-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-collections-dark { - background: url("/assets/icons/colored/collections-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-institutions-dark { - background: url("/assets/icons/colored/institutions-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-projects-dark { - background: url("/assets/icons/colored/projects-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; -} - -.custom-icon-meetings-dark { - background: url("/assets/icons/colored/meetings-dark.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/meetings.svg"); } .custom-icon-data { @@ -118,38 +67,23 @@ } .custom-icon-data-dark { - background: url("/assets/icons/colored/data.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/data.svg"); } .custom-icon-code-dark { - background: url("/assets/icons/colored/code.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/code.svg"); } .custom-icon-materials-dark { - background: url("/assets/icons/colored/materials.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/materials.svg"); } .custom-icon-papers-dark { - background: url("/assets/icons/colored/papers.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/papers.svg"); } .custom-icon-supplements-dark { - background: url("/assets/icons/colored/supplements.svg") no-repeat center; - background-size: contain; - width: 1rem; - height: 1rem; + @include custom-icon-mask("/assets/icons/colored/supplements.svg"); } .icon-resource-size { diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index a1b804d87..2351787f5 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -53,6 +53,7 @@ $base-font-size: 16px; --green-2-alt: #35aea9; --blue-1: #3792b1; --blue-2: #3379b7; + --blue-3: #00beff; // Background Colors --green-1-bg: #ebf7f3; diff --git a/src/styles/components/nodes.scss b/src/styles/components/nodes.scss index 87f4d0ccc..15b959ab4 100644 --- a/src/styles/components/nodes.scss +++ b/src/styles/components/nodes.scss @@ -19,9 +19,3 @@ color: var(--dark-blue-1); } } - -.activity-item { - a { - font-weight: bold; - } -} diff --git a/src/styles/components/preprints.scss b/src/styles/components/preprints.scss index 8989bbc15..9144dc920 100644 --- a/src/styles/components/preprints.scss +++ b/src/styles/components/preprints.scss @@ -12,10 +12,6 @@ .provider-description { line-height: 1.5rem; - - a { - font-weight: bold; - } } .search-container { @@ -106,13 +102,9 @@ } .preprints-footer-section { - padding-top: mix.rem(10px); - - a { - font-weight: bold; - } + padding-top: 0.6rem; p { - margin-bottom: mix.rem(10px); + margin-bottom: 0.6rem; } } diff --git a/src/testing/data/user/user.data.ts b/src/testing/data/user/user.data.ts new file mode 100644 index 000000000..5092cacef --- /dev/null +++ b/src/testing/data/user/user.data.ts @@ -0,0 +1,111 @@ +import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const'; +import { UserDataJsonApi, UserDataResponseJsonApi } from '@osf/shared/models/user/user-json-api.model'; + +const userDataJsonApi: UserDataJsonApi = { + id: '1', + type: 'users', + attributes: { + accepted_terms_of_service: false, + active: true, + allow_indexing: true, + can_view_reviews: true, + date_registered: '2024-01-01', + education: [ + { + degree: 'Bachelor of Science', + institution: 'University of Technology', + startYear: 2016, + startMonth: 1, + endYear: 2020, + endMonth: 1, + ongoing: false, + department: 'Computer Science', + }, + ], + employment: [ + { + title: 'Software Engineer', + institution: 'Tech Corp', + startYear: 2020, + startMonth: 1, + endYear: null, + endMonth: null, + ongoing: true, + department: 'Engineering', + }, + ], + family_name: 'Doe', + full_name: 'John Doe', + given_name: 'John', + middle_names: '', + suffix: '', + locale: 'en_US', + social: { + ssrn: '', + orcid: '0000-0000-0000-0000', + github: ['https://github.com/johndoe'], + scholar: '', + twitter: ['https://twitter.com/johndoe'], + linkedIn: ['https://linkedin.com/in/johndoe'], + impactStory: '', + baiduScholar: '', + researchGate: '', + researcherId: '', + profileWebsites: ['https://example.com/profile'], + academiaProfileID: '', + academiaInstitution: '', + }, + external_identity: {}, + timezone: 'Etc/UTC', + }, + relationships: { + default_region: { + data: { + id: 'us', + type: 'regions', + }, + }, + }, + links: { + html: 'https://example.com/profile', + iri: 'https://example.com/profile', + profile_image: 'https://example.com/profile.png', + self: 'https://api.test/v2/users/1/', + }, +}; + +const currentUserResponse: UserDataResponseJsonApi = { + meta: { + active_flags: [FEATURE_FLAGS.WORKFLOW_LAUNCHER], + current_user: { data: userDataJsonApi }, + }, +}; + +const loggedOutUserResponse: UserDataResponseJsonApi = { + meta: { + active_flags: [], + current_user: { data: null }, + }, +}; + +export function getCurrentUserData(): UserDataResponseJsonApi { + return structuredClone(currentUserResponse); +} + +export function getLoggedOutCurrentUserData(): UserDataResponseJsonApi { + return structuredClone(loggedOutUserResponse); +} + +export function getUserDataJsonApi(): UserDataJsonApi { + return structuredClone(userDataJsonApi); +} + +export function getAcceptedTermsUserDataJsonApi(): UserDataJsonApi { + return structuredClone({ + ...userDataJsonApi, + attributes: { + ...userDataJsonApi.attributes, + accepted_terms_of_service: true, + }, + }); +} diff --git a/src/testing/providers/storage.service.mock.ts b/src/testing/providers/storage.service.mock.ts new file mode 100644 index 000000000..b1b6a3d86 --- /dev/null +++ b/src/testing/providers/storage.service.mock.ts @@ -0,0 +1,56 @@ +import { Mock, vi } from 'vitest'; + +import { StorageService } from '@core/services/storage.service'; +import { UserModel } from '@osf/shared/models/user/user.model'; + +export type StorageServiceMockType = Pick< + StorageService, + 'getCachedUser' | 'setCachedUser' | 'getCachedActiveFlags' | 'setCachedActiveFlags' | 'clearSession' +> & { + getCachedUser: Mock; + setCachedUser: Mock; + getCachedActiveFlags: Mock; + setCachedActiveFlags: Mock; + clearSession: Mock; +}; + +export class StorageServiceMockBuilder { + private getCachedUserMock: Mock = vi.fn().mockReturnValue(null); + private setCachedUserMock: Mock = vi.fn(); + private getCachedActiveFlagsMock: Mock = vi.fn().mockReturnValue([]); + private setCachedActiveFlagsMock: Mock = vi.fn(); + private clearSessionMock: Mock = vi.fn(); + + static create(): StorageServiceMockBuilder { + return new StorageServiceMockBuilder(); + } + + withCachedUser(user: UserModel | null): StorageServiceMockBuilder { + this.getCachedUserMock.mockReturnValue(user); + return this; + } + + withCachedActiveFlags(flags: string[]): StorageServiceMockBuilder { + this.getCachedActiveFlagsMock.mockReturnValue(flags); + return this; + } + + build(): StorageServiceMockType { + return { + getCachedUser: this.getCachedUserMock, + setCachedUser: this.setCachedUserMock, + getCachedActiveFlags: this.getCachedActiveFlagsMock, + setCachedActiveFlags: this.setCachedActiveFlagsMock, + clearSession: this.clearSessionMock, + }; + } +} + +export const StorageServiceMock = { + create() { + return StorageServiceMockBuilder.create(); + }, + simple() { + return StorageServiceMockBuilder.create().build(); + }, +};