diff --git a/CHANGELOG b/CHANGELOG index 1daaac2b0..86e6d8e66 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,21 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +26.16.1 (2026-08-11) +==================== + +* Hotfix to update homepage and dashboard copy and images + +26.16.0 (2026-08-10) +==================== + +* Workflow Launcher + +26.15.0 (2026-08-03) +==================== + +* Improve tracking for download dashboard. + 26.14.0 (2026-07-20) ==================== diff --git a/package.json b/package.json index 1e47b65e8..d905ea529 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osf", - "version": "26.14.0", + "version": "26.16.1", "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", 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/files/pages/file-detail/file-detail.component.spec.ts b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts index 275f16279..55a33cd0e 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts @@ -168,11 +168,14 @@ describe('FileDetailComponent', () => { setup(); (store.dispatch as Mock).mockClear(); const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; component.downloadRevision('3'); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.fileMetadata$); - expect(openSpy).toHaveBeenCalledWith('https://osf.test/download/?revision=3'); + expect(openSpy).toHaveBeenCalledWith( + `https://osf.test/download/?revision=3&source=file-detail&tz=${encodeURIComponent(timeZone)}` + ); expect(store.dispatch).toHaveBeenCalledWith(new GetFileRevisions('https://osf.test/upload')); }); @@ -222,11 +225,14 @@ describe('FileDetailComponent', () => { setup(); (store.dispatch as Mock).mockClear(); const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; component.downloadFile(); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.fileMetadata$); - expect(openSpy).toHaveBeenCalledWith('https://osf.test/download'); + expect(openSpy).toHaveBeenCalledWith( + `https://osf.test/download?source=file-detail&tz=${encodeURIComponent(timeZone)}` + ); openSpy.mockRestore(); }); diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index e9d72d80d..e0335df8e 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -40,6 +40,7 @@ import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/meta import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { appendDownloadTrackingParams } from '@osf/shared/helpers/download-link.helper'; import { getMfrUrlWithVersion } from '@osf/shared/helpers/mfr-url.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; @@ -250,7 +251,8 @@ export class FileDetailComponent implements OnDestroy { const storageLink = this.file()?.links.upload || ''; if (downloadUrl) { - window.open(`${downloadUrl}/?revision=${version}`)?.focus(); + const link = appendDownloadTrackingParams(`${downloadUrl}/?revision=${version}`, 'file-detail'); + window.open(link)?.focus(); this.actions.getFileRevisions(storageLink); } } @@ -266,7 +268,7 @@ export class FileDetailComponent implements OnDestroy { .logIdentifiableDownload(this.fileMetadata$) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); - window.open(link)?.focus(); + window.open(appendDownloadTrackingParams(link, 'file-detail'))?.focus(); } deleteEntry(link: string): void { 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/data.ts b/src/app/features/home/constants/data.ts index 8b6b300c8..743d3ba10 100644 --- a/src/app/features/home/constants/data.ts +++ b/src/app/features/home/constants/data.ts @@ -62,17 +62,4 @@ export const INTEGRATION_ICONS = [ width: '152', }, }, - { - title: 'home.loggedOut.integrations.categories.storage', - first: { - path: 'assets/images/integrations/dropbox.png', - height: '34', - width: '175', - }, - second: { - path: 'assets/images/integrations/google-drive.png', - height: '35', - width: '207', - }, - }, ]; 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.html b/src/app/features/home/home.component.html index 2e89dcf60..d54df5233 100644 --- a/src/app/features/home/home.component.html +++ b/src/app/features/home/home.component.html @@ -1,20 +1,18 @@ -
-
+
+

-

+

{{ 'home.loggedOut.hero.subtitle' | translate }}

-
- -
+
@@ -24,17 +22,15 @@

priority height="377" width="585" - alt="Better research" + [alt]="'home.loggedOut.hero.betterResearchImageAltText' | translate" />
-
+
-
-
-

+
+

-

{{ 'home.loggedOut.discover.subtitle' | translate }}

-
+

{{ 'home.loggedOut.discover.subtitle' | translate }}

(triggerSearch)="redirectToSearchPageWithValue()" />
-
+ -
+

-
-
-
-
- Search - -
- -
-

- {{ 'home.loggedOut.support.sections.search.title' | translate }} -

- -

- {{ 'home.loggedOut.support.sections.search.description' | translate }} -

-
-
- -
-
- Design - -
- -
-

- {{ 'home.loggedOut.support.sections.design.title' | translate }} -

- -

- {{ 'home.loggedOut.support.sections.design.description' | translate }} -

-
-
- -
-
- Collect - -
- -
-

- {{ 'home.loggedOut.support.sections.collect.title' | translate }} -

- -

- {{ 'home.loggedOut.support.sections.collect.description' | translate }} -

-
-
- -
-
- Publish -
- -
-

- {{ 'home.loggedOut.support.sections.publish.title' | translate }} -

- -

- {{ 'home.loggedOut.support.sections.publish.description' | translate }} -

-
-
-
- -
- -
+
+
-
+
-
+

-
- 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) {
; + default_region: ToOneRelData<'regions'>; } diff --git a/src/app/shared/services/file-download.service.spec.ts b/src/app/shared/services/file-download.service.spec.ts index afb9e2372..5e6706eb6 100644 --- a/src/app/shared/services/file-download.service.spec.ts +++ b/src/app/shared/services/file-download.service.spec.ts @@ -44,7 +44,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/download/'); + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/download/', 'files'); expect(openSpy).toHaveBeenCalledWith('/folder/download/?zip=', '_blank'); }); @@ -63,6 +63,7 @@ describe('FileDownloadService', () => { it('logs datacite and opens file download link', () => { const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; service.downloadFile({ resourceId: 'node-1', @@ -71,11 +72,12 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(openSpy).toHaveBeenCalledWith('/file/download/', '_blank'); + expect(openSpy).toHaveBeenCalledWith(`/file/download/?source=files&tz=${encodeURIComponent(timeZone)}`, '_blank'); }); it('downloads file from file model', () => { const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const file = FileModelMock.simple({ kind: FileKind.File, links: { ...FileModelMock.simple().links, download: '/file/download/' }, @@ -88,7 +90,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(openSpy).toHaveBeenCalledWith('/file/download/', '_blank'); + expect(openSpy).toHaveBeenCalledWith(`/file/download/?source=files&tz=${encodeURIComponent(timeZone)}`, '_blank'); }); it('downloads folder zip from file model', () => { @@ -106,7 +108,7 @@ describe('FileDownloadService', () => { }); expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); - expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/upload/'); + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/folder/upload/', 'files'); expect(openSpy).toHaveBeenCalledWith('/folder/upload/?zip=', '_blank'); }); }); diff --git a/src/app/shared/services/file-download.service.ts b/src/app/shared/services/file-download.service.ts index 61f377a74..5999f8d79 100644 --- a/src/app/shared/services/file-download.service.ts +++ b/src/app/shared/services/file-download.service.ts @@ -2,12 +2,17 @@ import { isPlatformBrowser } from '@angular/common'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { appendDownloadTrackingParams } from '@osf/shared/helpers/download-link.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { FilesService } from '@osf/shared/services/files.service'; import { FileDownloadContext } from '../models/files/file-download-context.model'; +// Downloads that go through this service all originate from the files UI, so they're +// tagged with the 'files' source for download telemetry. +const DOWNLOAD_SOURCE = 'files'; + @Injectable({ providedIn: 'root', }) @@ -22,7 +27,7 @@ export class FileDownloadService { } this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); - this.openInNewTab(this.filesService.getFolderDownloadLink(downloadLink)); + this.openInNewTab(this.filesService.getFolderDownloadLink(downloadLink, DOWNLOAD_SOURCE)); } downloadFile({ resourceId, resourceType, downloadLink }: FileDownloadContext): void { @@ -31,7 +36,7 @@ export class FileDownloadService { } this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); - this.openInNewTab(downloadLink); + this.openInNewTab(appendDownloadTrackingParams(downloadLink, DOWNLOAD_SOURCE)); } downloadFileOrFolder(params: { resourceId: string; resourceType: string; file: FileModel }): void { @@ -44,12 +49,12 @@ export class FileDownloadService { this.dataciteService.logFileDownload(resourceId, resourceType).subscribe(); if (file.kind === FileKind.File) { - this.openInNewTab(file.links.download); + this.openInNewTab(appendDownloadTrackingParams(file.links.download, DOWNLOAD_SOURCE)); return; } if (file.links.upload) { - this.openInNewTab(this.filesService.getFolderDownloadLink(file.links.upload)); + this.openInNewTab(this.filesService.getFolderDownloadLink(file.links.upload, DOWNLOAD_SOURCE)); } } diff --git a/src/app/shared/services/files.service.spec.ts b/src/app/shared/services/files.service.spec.ts index f096634e9..106ba4aef 100644 --- a/src/app/shared/services/files.service.spec.ts +++ b/src/app/shared/services/files.service.spec.ts @@ -120,8 +120,11 @@ describe('FilesService', () => { it('should build folder download link with correct separator', () => { setup(); - expect(service.getFolderDownloadLink('/files/1')).toBe('/files/1?zip='); - expect(service.getFolderDownloadLink('/files/1?foo=bar')).toBe('/files/1?foo=bar&zip='); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(service.getFolderDownloadLink('/files/1')).toBe(`/files/1?zip=&source=&tz=${encodeURIComponent(timeZone)}`); + expect(service.getFolderDownloadLink('/files/1?foo=bar')).toBe( + `/files/1?foo=bar&zip=&source=&tz=${encodeURIComponent(timeZone)}` + ); }); it('should return empty reference when addons api response has no data', async () => { diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index e5bd9a7a7..2696460e0 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -21,6 +21,7 @@ import { ResourceCustomMetadataResponse } from '@osf/features/files/models/resou import { DEFAULT_TABLE_PARAMS } from '../constants/default-table-params.constants'; import { FileKind } from '../enums/file-kind.enum'; import { ResourceType } from '../enums/resource-type.enum'; +import { appendDownloadTrackingParams } from '../helpers/download-link.helper'; import { AddonMapper } from '../mappers/addon.mapper'; import { ContributorsMapper } from '../mappers/contributors'; import { FilesMapper } from '../mappers/files/files.mapper'; @@ -179,9 +180,9 @@ export class FilesService { return this.jsonApiService.post(link, body); } - getFolderDownloadLink(link: string): string { + getFolderDownloadLink(link: string, source = ''): string { const separator = link.includes('?') ? '&' : '?'; - return `${link}${separator}zip=`; + return appendDownloadTrackingParams(`${link}${separator}zip=`, source); } getFileTarget(fileGuid: string): Observable { diff --git a/src/app/shared/services/social-share.service.spec.ts b/src/app/shared/services/social-share.service.spec.ts index 04060b9f7..5e1c51ec6 100644 --- a/src/app/shared/services/social-share.service.spec.ts +++ b/src/app/shared/services/social-share.service.spec.ts @@ -79,9 +79,13 @@ describe('SocialShareService', () => { it('should create web urls', () => { setup(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(service.createPreprintUrl('pp-1', 'osf')).toBe('https://osf.test/preprints/osf/pp-1'); expect(service.createGuidUrl('abc12')).toBe('https://osf.test/abc12'); - expect(service.createDownloadUrl('res-1')).toBe('https://osf.test/download/res-1'); + expect(service.createDownloadUrl('res-1')).toBe( + `https://osf.test/download/res-1?source=&tz=${encodeURIComponent(timeZone)}` + ); }); it('should generate social action items from platform config', () => { diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts index d8c9832f2..faa5db904 100644 --- a/src/app/shared/services/social-share.service.ts +++ b/src/app/shared/services/social-share.service.ts @@ -4,6 +4,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { SOCIAL_PLATFORMS } from '../constants/social-platforms.const'; import { SOCIAL_SHARE_URLS } from '../constants/social-share.config'; +import { appendDownloadTrackingParams } from '../helpers/download-link.helper'; import { SocialShareContentModel } from '../models/socials/social-share-content.model'; import { SocialShareLinksModel } from '../models/socials/social-share-links.model'; import { SocialsShareActionItem } from '../models/socials/socials-share-action-item.model'; @@ -56,8 +57,8 @@ export class SocialShareService { return `${this.webUrl}/${guid}`; } - createDownloadUrl(resourceId: string): string { - return `${this.webUrl}/download/${resourceId}`; + createDownloadUrl(resourceId: string, source = ''): string { + return appendDownloadTrackingParams(`${this.webUrl}/download/${resourceId}`, source); } generateSocialActionItems(content: SocialShareContentModel): SocialsShareActionItem[] { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 87eb3ea27..88c5a57a1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -342,6 +342,43 @@ "results": "results" } }, + "chooseRepository": { + "title": "Choose a Repository for Your Research Materials", + "intro": "For the data, code, and materials that come out of your work, a dedicated repository is the right home. The repositories below are publicly trusted, and built to make your research outputs findable and citable for the long term. Discover more repository options at 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,7 +794,35 @@ "osfPreprintsImageAltTest": "OSF Preprints", "osfRegistriesImageAltTest": "OSF Registries" }, - "noCreatedProject": "You haven’t created a project yet. Click the \"Create New Project\" button above to get started.", + "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.", + "projectTransition": "New projects can no longer be created after November 16th, and all existing projects will become read-only on February 19th. You can read more about the upcoming transition here.", "quickSearch": { "goTo": "Go to", "myProjects": "My Projects", @@ -767,16 +832,12 @@ "toOrganize": "to organize your work or" }, "title": "Dashboard", - "watchVideoBelow": "Watch the short video below or visit the OSF Get Started help guides to learn more.", "welcome": "Welcome to OSF!" }, "latestResearch": { "button": "View Preprints", "subtitle": "Check out the latest preprints hosted on OSF covering a variety of research areas", "title": "Browse the latest research" - }, - "publicProjects": { - "title": "Discover Public Projects" } }, "loggedOut": { @@ -786,6 +847,7 @@ "title": "Discover Public Research" }, "hero": { + "betterResearchImageAltText": "Better research", "signUp": "Sign Up", "subtitle": "OSF is a free, open platform to support your research and enable collaboration", "title": "There's a better way to manage your research" @@ -794,31 +856,14 @@ "categories": { "authentication": "Authentication", "discovery": "Discovery", - "references": "References", - "storage": "Storage" + "references": "References" }, + "iconImageAltText": "Integration icon", "title": "OSF integrations make your workflow more efficient" }, "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", - "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", - "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", - "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", - "title": "Search And Discover" - } - }, - "title": "How OSF Supports your research" + "title": "How OSF Supports your research", + "connectedRecord": "Connected record" }, "testimonials": { "slides": { 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/connected-record.png b/src/assets/images/connected-record.png new file mode 100644 index 000000000..1ef296672 Binary files /dev/null and b/src/assets/images/connected-record.png differ diff --git a/src/assets/images/integrations/dropbox.png b/src/assets/images/integrations/dropbox.png deleted file mode 100644 index 9ad2193b5..000000000 Binary files a/src/assets/images/integrations/dropbox.png and /dev/null differ diff --git a/src/assets/images/integrations/google-drive.png b/src/assets/images/integrations/google-drive.png deleted file mode 100644 index f56595338..000000000 Binary files a/src/assets/images/integrations/google-drive.png and /dev/null differ 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(); + }, +};