From 9b4dbe2bb3b9b86b9291c099ad3a47d595808c92 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 22 Jan 2026 15:50:10 +0200 Subject: [PATCH 1/4] show user affiliated institutions for user profile --- .../profile-information.component.html | 19 +++++++++++ .../profile-information.component.ts | 4 +++ .../features/profile/profile.component.html | 7 +++- src/app/features/profile/profile.component.ts | 11 +++++++ .../shared/services/institutions.service.ts | 8 +++++ .../institutions/institutions.actions.ts | 6 ++++ .../stores/institutions/institutions.model.ts | 2 ++ .../institutions/institutions.selectors.ts | 5 +++ .../stores/institutions/institutions.state.ts | 32 +++++++++++++++++++ 9 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/app/features/profile/components/profile-information/profile-information.component.html b/src/app/features/profile/components/profile-information/profile-information.component.html index b4636126f..80ae98a4a 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.html +++ b/src/app/features/profile/components/profile-information/profile-information.component.html @@ -35,6 +35,25 @@

{{ currentUser()?.fullName }}

} +
+ @for (institution of currentUserInstitutions(); track $index) { + + + + } +
+ @if (!isMedium() && showEdit()) {
(); + currentUserInstitutions = input(); showEdit = input(false); editProfile = output(); diff --git a/src/app/features/profile/profile.component.html b/src/app/features/profile/profile.component.html index 4176e33fc..5c582e3f9 100644 --- a/src/app/features/profile/profile.component.html +++ b/src/app/features/profile/profile.component.html @@ -13,7 +13,12 @@ } - +
@if (defaultSearchFiltersInitialized()) { diff --git a/src/app/features/profile/profile.component.ts b/src/app/features/profile/profile.component.ts index fb7c186a8..60740c8c6 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -25,6 +25,7 @@ import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants/search-tab-options.con import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserModel } from '@osf/shared/models/user/user.models'; import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; +import { FetchUserInstitutionsById, InstitutionsSelectors } from '@shared/stores/institutions'; import { ProfileInformationComponent } from './components'; import { FetchUserProfile, ProfileSelectors, SetUserProfile } from './store'; @@ -46,11 +47,13 @@ export class ProfileComponent implements OnInit, OnDestroy { fetchUserProfile: FetchUserProfile, setDefaultFilterValue: SetDefaultFilterValue, setUserProfile: SetUserProfile, + fetchUserInstitutionsById: FetchUserInstitutionsById, }); loggedInUser = select(UserSelectors.getCurrentUser); userProfile = select(ProfileSelectors.getUserProfile); isUserLoading = select(ProfileSelectors.isUserProfileLoading); + institutions = select(InstitutionsSelectors.getUserProfileInstitutions); resourceTabOptions = SEARCH_TAB_OPTIONS.filter((x) => x.value !== ResourceType.Agent); @@ -58,6 +61,13 @@ export class ProfileComponent implements OnInit, OnDestroy { user = computed(() => (this.isMyProfile() ? this.loggedInUser() : this.userProfile())); defaultSearchFiltersInitialized = signal(false); + currentUserInstitutions = computed(() => { + const user = this.user(); + const allInstitutions = this.institutions(); + if (!user || !user.id) return null; + return allInstitutions[user.id]; + }); + ngOnInit(): void { const userId = this.route.snapshot.params['id']; const currentUser = this.loggedInUser(); @@ -67,6 +77,7 @@ export class ProfileComponent implements OnInit, OnDestroy { } else if (currentUser) { this.setupMyProfile(currentUser); } + this.actions.fetchUserInstitutionsById(userId || currentUser?.id); } ngOnDestroy(): void { diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index 9858f02f2..08ae34c01 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -55,6 +55,14 @@ export class InstitutionsService { .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); } + getUserInstitutionsById(userId: string): Observable { + const url = `${this.apiUrl}/users/${userId}/institutions/`; + + return this.jsonApiService + .get(url) + .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); + } + getInstitutionById(institutionId: string): Observable { return this.jsonApiService .get(`${this.apiUrl}/institutions/${institutionId}/`) diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 7645e7d6b..00d7b0ba4 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -5,6 +5,12 @@ export class FetchUserInstitutions { static readonly type = '[Institutions] Fetch User Institutions'; } +export class FetchUserInstitutionsById { + static readonly type = '[Institutions] Fetch User Institutions By Id'; + + constructor(public userId: string) {} +} + export class FetchInstitutions { static readonly type = '[Institutions] Fetch Institutions'; diff --git a/src/app/shared/stores/institutions/institutions.model.ts b/src/app/shared/stores/institutions/institutions.model.ts index 984d18a5e..c45c2884f 100644 --- a/src/app/shared/stores/institutions/institutions.model.ts +++ b/src/app/shared/stores/institutions/institutions.model.ts @@ -4,6 +4,7 @@ import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-w export interface InstitutionsStateModel { userInstitutions: AsyncStateModel; + userInstitutionsById: Record>; institutions: AsyncStateWithTotalCount; resourceInstitutions: AsyncStateModel; } @@ -14,6 +15,7 @@ export const INSTITUTIONS_STATE_DEFAULTS: InstitutionsStateModel = { isLoading: false, error: null, }, + userInstitutionsById: {}, institutions: { data: [], isLoading: false, diff --git a/src/app/shared/stores/institutions/institutions.selectors.ts b/src/app/shared/stores/institutions/institutions.selectors.ts index f4b0df22e..df44df5eb 100644 --- a/src/app/shared/stores/institutions/institutions.selectors.ts +++ b/src/app/shared/stores/institutions/institutions.selectors.ts @@ -9,6 +9,11 @@ export class InstitutionsSelectors { return state.userInstitutions.data; } + @Selector([InstitutionsState]) + static getUserProfileInstitutions(state: InstitutionsStateModel) { + return state.userInstitutionsById; + } + @Selector([InstitutionsState]) static areUserInstitutionsLoading(state: InstitutionsStateModel) { return state.userInstitutions.isLoading; diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts index 631f9ec56..37a21e2d3 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -12,6 +12,7 @@ import { FetchInstitutions, FetchResourceInstitutions, FetchUserInstitutions, + FetchUserInstitutionsById, UpdateResourceInstitutions, } from './institutions.actions'; import { INSTITUTIONS_STATE_DEFAULTS, InstitutionsStateModel } from './institutions.model'; @@ -43,6 +44,37 @@ export class InstitutionsState { ); } + @Action(FetchUserInstitutionsById) + getUserInstitutionsById(ctx: StateContext, action: FetchUserInstitutionsById) { + const userId = action.userId; + const current = ctx.getState().userInstitutionsById || {}; + ctx.patchState({ + userInstitutionsById: { + ...current, + [userId]: { + ...(current[userId] || { data: [], isLoading: true, error: null }), + }, + }, + }); + + return this.institutionsService.getUserInstitutionsById(action.userId).pipe( + tap((institutions) => { + const current = ctx.getState().userInstitutionsById || {}; + ctx.patchState({ + userInstitutionsById: { + ...current, + [userId]: { + data: institutions, + isLoading: false, + error: null, + }, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'userInstitutionsById', error)) + ); + } + @Action(FetchInstitutions) fetchInstitutions(ctx: StateContext, action: FetchInstitutions) { ctx.patchState({ From 8e55479e4761274b33ce927fb2cc2661a9f74fe8 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 23 Jan 2026 01:18:21 +0200 Subject: [PATCH 2/4] reuse existing getUserInstitutions with default userID parameter to show user related institutions --- .../features/profile/profile.component.html | 2 +- src/app/features/profile/profile.component.ts | 16 +++------ .../store/account-settings.actions.ts | 2 ++ .../store/account-settings.state.ts | 4 +-- .../shared/services/institutions.service.ts | 10 +----- .../institutions/institutions.actions.ts | 7 +--- .../stores/institutions/institutions.state.ts | 36 ++----------------- 7 files changed, 14 insertions(+), 63 deletions(-) diff --git a/src/app/features/profile/profile.component.html b/src/app/features/profile/profile.component.html index 5c582e3f9..958d22e05 100644 --- a/src/app/features/profile/profile.component.html +++ b/src/app/features/profile/profile.component.html @@ -14,7 +14,7 @@ } x.value !== ResourceType.Agent); @@ -61,13 +61,6 @@ export class ProfileComponent implements OnInit, OnDestroy { user = computed(() => (this.isMyProfile() ? this.loggedInUser() : this.userProfile())); defaultSearchFiltersInitialized = signal(false); - currentUserInstitutions = computed(() => { - const user = this.user(); - const allInstitutions = this.institutions(); - if (!user || !user.id) return null; - return allInstitutions[user.id]; - }); - ngOnInit(): void { const userId = this.route.snapshot.params['id']; const currentUser = this.loggedInUser(); @@ -77,7 +70,8 @@ export class ProfileComponent implements OnInit, OnDestroy { } else if (currentUser) { this.setupMyProfile(currentUser); } - this.actions.fetchUserInstitutionsById(userId || currentUser?.id); + + this.actions.fetchUserInstitutions(userId || currentUser?.id); } ngOnDestroy(): void { diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index db53fa534..22ca549d6 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -24,6 +24,8 @@ export class DeleteExternalIdentity { export class GetUserInstitutions { static readonly type = '[AccountSettings] Get User Institutions'; + + constructor(public userId: string = 'me') {} } export class DeleteUserInstitution { diff --git a/src/app/features/settings/account-settings/store/account-settings.state.ts b/src/app/features/settings/account-settings/store/account-settings.state.ts index eee615405..9382c7ee0 100644 --- a/src/app/features/settings/account-settings/store/account-settings.state.ts +++ b/src/app/features/settings/account-settings/store/account-settings.state.ts @@ -84,8 +84,8 @@ export class AccountSettingsState { } @Action(GetUserInstitutions) - getUserInstitutions(ctx: StateContext) { - return this.institutionsService.getUserInstitutions().pipe( + getUserInstitutions(ctx: StateContext, action: GetUserInstitutions) { + return this.institutionsService.getUserInstitutions(action.userId).pipe( tap((userInstitutions) => ctx.patchState({ userInstitutions })), catchError((error) => throwError(() => error)) ); diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index 08ae34c01..b90fd89ea 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -47,15 +47,7 @@ export class InstitutionsService { .pipe(map((response) => InstitutionsMapper.fromResponseWithMeta(response))); } - getUserInstitutions(): Observable { - const url = `${this.apiUrl}/users/me/institutions/`; - - return this.jsonApiService - .get(url) - .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); - } - - getUserInstitutionsById(userId: string): Observable { + getUserInstitutions(userId: string): Observable { const url = `${this.apiUrl}/users/${userId}/institutions/`; return this.jsonApiService diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 00d7b0ba4..2958fc0fc 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -3,12 +3,7 @@ import { Institution } from '@shared/models/institutions/institutions.models'; export class FetchUserInstitutions { static readonly type = '[Institutions] Fetch User Institutions'; -} - -export class FetchUserInstitutionsById { - static readonly type = '[Institutions] Fetch User Institutions By Id'; - - constructor(public userId: string) {} + constructor(public userId: string = 'me') {} } export class FetchInstitutions { diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts index 37a21e2d3..757012656 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -12,7 +12,6 @@ import { FetchInstitutions, FetchResourceInstitutions, FetchUserInstitutions, - FetchUserInstitutionsById, UpdateResourceInstitutions, } from './institutions.actions'; import { INSTITUTIONS_STATE_DEFAULTS, InstitutionsStateModel } from './institutions.model'; @@ -26,10 +25,10 @@ export class InstitutionsState { private readonly institutionsService = inject(InstitutionsService); @Action(FetchUserInstitutions) - getUserInstitutions(ctx: StateContext) { + getUserInstitutions(ctx: StateContext, action: FetchUserInstitutions) { ctx.setState(patch({ userInstitutions: patch({ isLoading: true }) })); - return this.institutionsService.getUserInstitutions().pipe( + return this.institutionsService.getUserInstitutions(action.userId).pipe( tap((institutions) => { ctx.setState( patch({ @@ -44,37 +43,6 @@ export class InstitutionsState { ); } - @Action(FetchUserInstitutionsById) - getUserInstitutionsById(ctx: StateContext, action: FetchUserInstitutionsById) { - const userId = action.userId; - const current = ctx.getState().userInstitutionsById || {}; - ctx.patchState({ - userInstitutionsById: { - ...current, - [userId]: { - ...(current[userId] || { data: [], isLoading: true, error: null }), - }, - }, - }); - - return this.institutionsService.getUserInstitutionsById(action.userId).pipe( - tap((institutions) => { - const current = ctx.getState().userInstitutionsById || {}; - ctx.patchState({ - userInstitutionsById: { - ...current, - [userId]: { - data: institutions, - isLoading: false, - error: null, - }, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'userInstitutionsById', error)) - ); - } - @Action(FetchInstitutions) fetchInstitutions(ctx: StateContext, action: FetchInstitutions) { ctx.patchState({ From 38089106a313297c39758f641905cf1e012cf6d8 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 23 Jan 2026 01:23:34 +0200 Subject: [PATCH 3/4] fix linter --- .../settings/account-settings/store/account-settings.actions.ts | 2 +- src/app/shared/stores/institutions/institutions.actions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index 22ca549d6..e4ba12de7 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -25,7 +25,7 @@ export class DeleteExternalIdentity { export class GetUserInstitutions { static readonly type = '[AccountSettings] Get User Institutions'; - constructor(public userId: string = 'me') {} + constructor(public userId = 'me') {} } export class DeleteUserInstitution { diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 2958fc0fc..4e7790f79 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -3,7 +3,7 @@ import { Institution } from '@shared/models/institutions/institutions.models'; export class FetchUserInstitutions { static readonly type = '[Institutions] Fetch User Institutions'; - constructor(public userId: string = 'me') {} + constructor(public userId = 'me') {} } export class FetchInstitutions { From e0f5d3431fd3d75572b586a152acfe9122feeff8 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 23 Jan 2026 16:34:11 +0200 Subject: [PATCH 4/4] resolve CR comments --- .../profile-information.component.spec.ts | 27 +++++++++++++++++++ .../profile-information.component.ts | 1 + .../add-project-form.component.spec.ts | 2 +- .../stores/institutions/institutions.model.ts | 2 -- .../institutions/institutions.selectors.ts | 5 ---- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts index b88e9c062..52adcbdb4 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.spec.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.spec.ts @@ -7,12 +7,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; +import { Institution } from '@shared/models/institutions/institutions.models'; import { SocialModel } from '@shared/models/user/social.model'; import { UserModel } from '@shared/models/user/user.models'; import { ProfileInformationComponent } from './profile-information.component'; import { MOCK_USER } from '@testing/mocks/data.mock'; +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; import { MOCK_EDUCATION, MOCK_EMPLOYMENT } from '@testing/mocks/user-employment-education.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; @@ -44,6 +46,7 @@ describe('ProfileInformationComponent', () => { it('should initialize with default inputs', () => { expect(component.currentUser()).toBeUndefined(); expect(component.showEdit()).toBe(false); + expect(component.currentUserInstitutions()).toBeUndefined(); }); it('should accept user input', () => { @@ -172,4 +175,28 @@ describe('ProfileInformationComponent', () => { component.toProfileSettings(); expect(component.editProfile.emit).toHaveBeenCalled(); }); + + it('should accept currentUserInstitutions input', () => { + const mockInstitutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', mockInstitutions); + fixture.detectChanges(); + expect(component.currentUserInstitutions()).toEqual(mockInstitutions); + }); + + it('should not render institution logos when currentUserInstitutions is undefined', () => { + fixture.componentRef.setInput('currentUserInstitutions', undefined); + fixture.detectChanges(); + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(0); + }); + + it('should render institution logos when currentUserInstitutions is provided', () => { + const institutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', institutions); + fixture.detectChanges(); + + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(institutions.length); + expect(logos[0].alt).toBe(institutions[0].name); + }); }); diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index 2453bf367..0740068b0 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -35,6 +35,7 @@ import { mapUserSocials } from '../../helpers'; }) export class ProfileInformationComponent { currentUser = input(); + currentUserInstitutions = input(); showEdit = input(false); editProfile = output(); diff --git a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index 54336feae..ee325c8f8 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.spec.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts @@ -9,11 +9,11 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { UserSelectors } from '@core/store/user'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; -import { ProjectModel } from '@osf/shared/models/projects'; import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { ProjectsSelectors } from '@osf/shared/stores/projects'; import { RegionsSelectors } from '@osf/shared/stores/regions'; import { ProjectForm } from '@shared/models/projects/create-project-form.model'; +import { ProjectModel } from '@shared/models/projects/projects.models'; import { AffiliatedInstitutionSelectComponent } from '../affiliated-institution-select/affiliated-institution-select.component'; import { ProjectSelectorComponent } from '../project-selector/project-selector.component'; diff --git a/src/app/shared/stores/institutions/institutions.model.ts b/src/app/shared/stores/institutions/institutions.model.ts index c45c2884f..984d18a5e 100644 --- a/src/app/shared/stores/institutions/institutions.model.ts +++ b/src/app/shared/stores/institutions/institutions.model.ts @@ -4,7 +4,6 @@ import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-w export interface InstitutionsStateModel { userInstitutions: AsyncStateModel; - userInstitutionsById: Record>; institutions: AsyncStateWithTotalCount; resourceInstitutions: AsyncStateModel; } @@ -15,7 +14,6 @@ export const INSTITUTIONS_STATE_DEFAULTS: InstitutionsStateModel = { isLoading: false, error: null, }, - userInstitutionsById: {}, institutions: { data: [], isLoading: false, diff --git a/src/app/shared/stores/institutions/institutions.selectors.ts b/src/app/shared/stores/institutions/institutions.selectors.ts index df44df5eb..f4b0df22e 100644 --- a/src/app/shared/stores/institutions/institutions.selectors.ts +++ b/src/app/shared/stores/institutions/institutions.selectors.ts @@ -9,11 +9,6 @@ export class InstitutionsSelectors { return state.userInstitutions.data; } - @Selector([InstitutionsState]) - static getUserProfileInstitutions(state: InstitutionsStateModel) { - return state.userInstitutionsById; - } - @Selector([InstitutionsState]) static areUserInstitutionsLoading(state: InstitutionsStateModel) { return state.userInstitutions.isLoading;