diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 924c74427..79f8d23fe 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -183,6 +183,10 @@ export const routes: Routes = [ provideStates([MyProfileResourceFiltersState, MyProfileResourceFiltersOptionsState, MyProfileState]), ], }, + { + path: 'institutions', + loadChildren: () => import('./features/institutions/institutions.routes').then((r) => r.routes), + }, { path: 'confirm/:userId/:token', loadComponent: () => import('./features/home/home.component').then((mod) => mod.HomeComponent), diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index 5421c6368..960ffa864 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -53,6 +53,13 @@ export const NAV_ITEMS: NavItem[] = [ icon: 'profile', useExactMatch: true, }, + { + path: '/institutions', + label: 'navigation.institutions', + icon: 'institutions', + useExactMatch: true, + }, + { path: '/collections', label: 'navigation.collections', diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 7f622dcdd..d04e13eb9 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -1,7 +1,6 @@ import { AuthState } from '@core/store/auth'; import { UserState } from '@core/store/user'; import { CollectionsState } from '@osf/features/collections/store'; -import { InstitutionsState } from '@osf/features/institutions/store'; import { MeetingsState } from '@osf/features/meetings/store'; import { MyProjectsState } from '@osf/features/my-projects/store'; import { AnalyticsState } from '@osf/features/project/analytics/store'; @@ -14,6 +13,7 @@ import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store' import { NotificationSubscriptionState } from '@osf/features/settings/notifications/store'; import { ProfileSettingsState } from '@osf/features/settings/profile-settings/store/profile-settings.state'; import { TokensState } from '@osf/features/settings/tokens/store'; +import { InstitutionsState } from '@shared/stores'; import { AddonsState } from '@shared/stores/addons'; export const STATES = [ diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index a9391fbf3..8215dc56f 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -15,14 +15,14 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants'; +import { MyProjectsItem } from '@osf/features/my-projects/models'; import { AddProjectFormComponent, MyProjectsTableComponent, SubHeaderComponent } from '@osf/shared/components'; import { SortOrder } from '@osf/shared/enums'; import { TableParameters } from '@osf/shared/models'; import { IS_MEDIUM } from '@osf/shared/utils'; +import { GetUserInstitutions } from '@shared/stores'; -import { GetUserInstitutions } from '../institutions/store'; import { MyProjectsSearchFilters } from '../my-projects/models'; -import { MyProjectsItem } from '../my-projects/models/my-projects.models'; import { ClearMyProjects, GetMyProjects, MyProjectsSelectors } from '../my-projects/store'; import { AccountSettingsService } from '../settings/account-settings/services'; diff --git a/src/app/features/institutions/institutions.component.html b/src/app/features/institutions/institutions.component.html new file mode 100644 index 000000000..505a518b9 --- /dev/null +++ b/src/app/features/institutions/institutions.component.html @@ -0,0 +1,34 @@ +
+ + + @if (institutionsLoading()) { +
+ +
+ } @else { +
+ + +
+ @for (institution of institutions(); track $index) { +
+ + +

{{ institution.name }}

+
+ } +
+ + +
+ } +
diff --git a/src/app/features/institutions/institutions.component.scss b/src/app/features/institutions/institutions.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/institutions/institutions.component.spec.ts b/src/app/features/institutions/institutions.component.spec.ts new file mode 100644 index 000000000..e8e7def49 --- /dev/null +++ b/src/app/features/institutions/institutions.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsComponent } from './institutions.component'; + +describe('InstitutionsComponent', () => { + let component: InstitutionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts new file mode 100644 index 000000000..2b5557c25 --- /dev/null +++ b/src/app/features/institutions/institutions.component.ts @@ -0,0 +1,123 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { PaginatorState } from 'primeng/paginator'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { parseQueryFilterParams } from '@core/helpers'; +import { + CustomPaginatorComponent, + LoadingSpinnerComponent, + SearchInputComponent, + SubHeaderComponent, +} from '@shared/components'; +import { TABLE_PARAMS } from '@shared/constants'; +import { QueryParams } from '@shared/models'; +import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; + +@Component({ + selector: 'osf-institutions', + imports: [ + SubHeaderComponent, + TranslatePipe, + SearchInputComponent, + NgOptimizedImage, + CustomPaginatorComponent, + LoadingSpinnerComponent, + ], + templateUrl: './institutions.component.html', + styleUrl: './institutions.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + private readonly actions = createDispatchMap({ getInstitutions: FetchInstitutions }); + + searchControl = new FormControl(''); + + queryParams = toSignal(this.route.queryParams); + currentPage = signal(1); + currentPageSize = signal(TABLE_PARAMS.rows); + first = signal(0); + + institutions = select(InstitutionsSelectors.getInstitutions); + totalInstitutionsCount = select(InstitutionsSelectors.getInstitutionsTotalCount); + institutionsLoading = select(InstitutionsSelectors.isInstitutionsLoading); + + constructor() { + this.setupQueryParamsEffect(); + this.setupSearchSubscription(); + } + + onPageChange(event: PaginatorState): void { + this.currentPage.set(event.page ? this.currentPage() + 1 : 1); + this.first.set(event.first ?? 0); + this.updateQueryParams({ + page: this.currentPage(), + size: event.rows, + }); + } + + private setupQueryParamsEffect(): void { + effect(() => { + const rawQueryParams = this.queryParams(); + if (!rawQueryParams) return; + + const parsedQueryParams = parseQueryFilterParams(rawQueryParams); + + this.updateComponentState(parsedQueryParams); + + this.actions.getInstitutions(parsedQueryParams.page, parsedQueryParams.size, parsedQueryParams.search); + }); + } + + private updateQueryParams(updates: Partial): void { + const queryParams: Record = {}; + + if ('page' in updates) { + queryParams['page'] = updates.page!.toString(); + } + if ('size' in updates) { + queryParams['size'] = updates.size!.toString(); + } + if ('search' in updates) { + queryParams['search'] = updates.search || undefined; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + private setupSearchSubscription(): void { + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((searchControl) => { + this.updateQueryParams({ + search: searchControl ?? '', + page: 1, + }); + }); + } + + private updateComponentState(params: QueryParams): void { + untracked(() => { + this.currentPage.set(params.page); + this.currentPageSize.set(params.size); + this.searchControl.setValue(params.search); + }); + } +} diff --git a/src/app/features/institutions/institutions.routes.ts b/src/app/features/institutions/institutions.routes.ts new file mode 100644 index 000000000..4d506a60d --- /dev/null +++ b/src/app/features/institutions/institutions.routes.ts @@ -0,0 +1,10 @@ +import { Routes } from '@angular/router'; + +import { InstitutionsComponent } from '@osf/features/institutions/institutions.component'; + +export const routes: Routes = [ + { + path: '', + component: InstitutionsComponent, + }, +]; diff --git a/src/app/features/institutions/mappers/index.ts b/src/app/features/institutions/mappers/index.ts deleted file mode 100644 index d43e7a986..000000000 --- a/src/app/features/institutions/mappers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './institutions.mapper'; diff --git a/src/app/features/institutions/models/index.ts b/src/app/features/institutions/models/index.ts deleted file mode 100644 index 5ce1b7814..000000000 --- a/src/app/features/institutions/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './institutions.models'; diff --git a/src/app/features/institutions/services/index.ts b/src/app/features/institutions/services/index.ts deleted file mode 100644 index a5a38741c..000000000 --- a/src/app/features/institutions/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { InstitutionsService } from './institutions.service'; diff --git a/src/app/features/institutions/services/institutions.service.ts b/src/app/features/institutions/services/institutions.service.ts deleted file mode 100644 index ad09830e8..000000000 --- a/src/app/features/institutions/services/institutions.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { inject, Injectable } from '@angular/core'; - -import { JsonApiResponse } from '@osf/core/models'; -import { JsonApiService } from '@osf/core/services'; - -import { InstitutionsMapper } from '../mappers'; -import { Institution, UserInstitutionGetResponse } from '../models'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class InstitutionsService { - #jsonApiService = inject(JsonApiService); - - getUserInstitutions(): Observable { - const url = `${environment.apiUrl}/users/me/institutions/`; - - return this.#jsonApiService - .get>(url) - .pipe(map((response) => response.data.map((item) => InstitutionsMapper.fromResponse(item)))); - } - - deleteUserInstitution(id: string, userId: string): Observable { - const payload = { - data: [{ id: id, type: 'institutions' }], - }; - return this.#jsonApiService.delete(`${environment.apiUrl}/users/${userId}/relationships/institutions/`, payload); - } -} diff --git a/src/app/features/institutions/store/institutions.actions.ts b/src/app/features/institutions/store/institutions.actions.ts deleted file mode 100644 index 2af0d870e..000000000 --- a/src/app/features/institutions/store/institutions.actions.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class GetUserInstitutions { - static readonly type = '[Institutions] Get User Institutions'; -} diff --git a/src/app/features/institutions/store/institutions.model.ts b/src/app/features/institutions/store/institutions.model.ts deleted file mode 100644 index da0e445be..000000000 --- a/src/app/features/institutions/store/institutions.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Institution } from '../models'; - -export interface InstitutionsStateModel { - userInstitutions: Institution[]; -} diff --git a/src/app/features/institutions/store/institutions.selectors.ts b/src/app/features/institutions/store/institutions.selectors.ts deleted file mode 100644 index 278497136..000000000 --- a/src/app/features/institutions/store/institutions.selectors.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { InstitutionsStateModel } from './institutions.model'; -import { InstitutionsState } from './institutions.state'; - -export class InstitutionsSelectors { - @Selector([InstitutionsState]) - static getUserInstitutions(state: InstitutionsStateModel) { - return state.userInstitutions; - } -} diff --git a/src/app/features/institutions/store/institutions.state.ts b/src/app/features/institutions/store/institutions.state.ts deleted file mode 100644 index 73649d8d4..000000000 --- a/src/app/features/institutions/store/institutions.state.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Action, State, StateContext } from '@ngxs/store'; - -import { tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { InstitutionsService } from '../services'; - -import { GetUserInstitutions } from './institutions.actions'; -import { InstitutionsStateModel } from './institutions.model'; - -@State({ - name: 'institutions', - defaults: { - userInstitutions: [], - }, -}) -@Injectable() -export class InstitutionsState { - #institutionsService = inject(InstitutionsService); - - @Action(GetUserInstitutions) - getUserInstitutions(ctx: StateContext) { - return this.#institutionsService.getUserInstitutions().pipe( - tap((institutions) => { - ctx.patchState({ - userInstitutions: institutions, - }); - }) - ); - } -} diff --git a/src/app/features/meetings/constants/index.ts b/src/app/features/meetings/constants/index.ts index 8042264ba..361431603 100644 --- a/src/app/features/meetings/constants/index.ts +++ b/src/app/features/meetings/constants/index.ts @@ -1,3 +1,2 @@ export * from './meeting-submissions-table.constants'; export * from './meetings-fields.constants'; -export * from './meetings-table.constants'; diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts index 998d8d6dd..2740a9cb7 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.ts @@ -25,11 +25,11 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { parseQueryFilterParams } from '@core/helpers'; -import { MEETINGS_TABLE_PARAMS } from '@osf/features/meetings/constants'; import { Meeting } from '@osf/features/meetings/models'; import { GetAllMeetings, MeetingsSelectors } from '@osf/features/meetings/store'; import { IS_XSMALL } from '@osf/shared/utils'; import { SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { TABLE_PARAMS } from '@shared/constants'; import { SortOrder } from '@shared/enums'; import { QueryParams, TableParameters } from '@shared/models'; import { SearchFilters } from '@shared/models/filters'; @@ -55,9 +55,9 @@ export class MeetingsLandingComponent { sortColumn = signal(''); sortOrder = signal(SortOrder.Asc); currentPage = signal(1); - currentPageSize = signal(MEETINGS_TABLE_PARAMS.rows); + currentPageSize = signal(TABLE_PARAMS.rows); tableParams = signal({ - ...MEETINGS_TABLE_PARAMS, + ...TABLE_PARAMS, firstRowIndex: 0, }); diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts index d931c879b..0b17f1651 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -14,7 +14,7 @@ import { ActivatedRoute } from '@angular/router'; import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@shared/utils'; -import { InstitutionsState } from '../institutions/store'; +import { InstitutionsState } from '../../shared/stores/institutions'; import { MyProjectsState } from './store/my-projects.state'; import { MyProjectsComponent } from './my-projects.component'; diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 6539622fb..19fcb6324 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -31,8 +31,8 @@ import { SortOrder } from '@osf/shared/enums'; import { QueryParams, TableParameters, TabOption } from '@osf/shared/models'; import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@osf/shared/utils'; +import { GetUserInstitutions } from '../../shared/stores/institutions'; import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store'; -import { GetUserInstitutions } from '../institutions/store'; import { MyProjectsItem, MyProjectsSearchFilters } from './models'; import { diff --git a/src/app/features/settings/account-settings/store/account-settings.model.ts b/src/app/features/settings/account-settings/store/account-settings.model.ts index 2692056cb..0c91d5e66 100644 --- a/src/app/features/settings/account-settings/store/account-settings.model.ts +++ b/src/app/features/settings/account-settings/store/account-settings.model.ts @@ -1,4 +1,4 @@ -import { Institution } from '@osf/features/institutions/models'; +import { Institution } from '@shared/models'; import { AccountEmail, AccountSettings, ExternalIdentity, Region } from '../models'; diff --git a/src/app/features/settings/account-settings/store/account-settings.selectors.ts b/src/app/features/settings/account-settings/store/account-settings.selectors.ts index 6e5333247..b109dcae5 100644 --- a/src/app/features/settings/account-settings/store/account-settings.selectors.ts +++ b/src/app/features/settings/account-settings/store/account-settings.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { Institution } from '@osf/features/institutions/models'; +import { Institution } from '@shared/models'; import { AccountEmail, AccountSettings, ExternalIdentity, Region } from '../models'; 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 4e19b011a..852d4e815 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 @@ -5,7 +5,7 @@ import { finalize, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { SetCurrentUser } from '@core/store/user'; -import { InstitutionsService } from '@osf/features/institutions/services'; +import { InstitutionsService } from '@shared/services'; import { AccountSettingsService } from '../services'; 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 89eb60d67..756445ad3 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 @@ -10,12 +10,13 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MY_PROJECTS_TABLE_PARAMS } from '@osf/core/constants/my-projects-table.constants'; -import { InstitutionsState } from '@osf/features/institutions/store'; import { CreateProject, GetMyProjects, MyProjectsState } from '@osf/features/my-projects/store'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { AddProjectFormComponent } from './add-project-form.component'; +import { InstitutionsState } from 'src/app/shared/stores/institutions'; + describe('AddProjectFormComponent', () => { let component: AddProjectFormComponent; let fixture: ComponentFixture; diff --git a/src/app/shared/components/add-project-form/add-project-form.component.ts b/src/app/shared/components/add-project-form/add-project-form.component.ts index 431820943..916c6e738 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.ts +++ b/src/app/shared/components/add-project-form/add-project-form.component.ts @@ -16,13 +16,14 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { MY_PROJECTS_TABLE_PARAMS } from '@core/constants/my-projects-table.constants'; import { STORAGE_LOCATIONS } from '@core/constants/storage-locations.constant'; -import { InstitutionsSelectors } from '@osf/features/institutions/store'; import { CreateProject, GetMyProjects, MyProjectsSelectors } from '@osf/features/my-projects/store'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; import { ProjectForm } from '@osf/shared/models/create-project-form.model'; import { CustomValidators } from '@osf/shared/utils'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; +import { InstitutionsSelectors } from 'src/app/shared/stores/institutions'; + @Component({ selector: 'osf-add-project-form', imports: [ diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 0d5c616ba..0c394175f 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -11,7 +11,6 @@ import { isCitationAddon } from '@shared/utils'; @Component({ selector: 'osf-addon-terms', - standalone: true, imports: [TranslatePipe, TableModule, NgClass], templateUrl: './addon-terms.component.html', styleUrls: ['./addon-terms.component.scss'], diff --git a/src/app/shared/components/sub-header/sub-header.component.html b/src/app/shared/components/sub-header/sub-header.component.html index e2cc778eb..78a577e25 100644 --- a/src/app/shared/components/sub-header/sub-header.component.html +++ b/src/app/shared/components/sub-header/sub-header.component.html @@ -30,6 +30,6 @@

@if (description()) { -

{{ description() }}

+

} diff --git a/src/app/shared/components/sub-header/sub-header.component.ts b/src/app/shared/components/sub-header/sub-header.component.ts index 449290e4b..3e2fb91b0 100644 --- a/src/app/shared/components/sub-header/sub-header.component.ts +++ b/src/app/shared/components/sub-header/sub-header.component.ts @@ -1,4 +1,5 @@ import { Button } from 'primeng/button'; +import { SafeHtmlPipe } from 'primeng/menu'; import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; @@ -6,7 +7,7 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core @Component({ selector: 'osf-sub-header', - imports: [Button, Tooltip, Skeleton], + imports: [Button, Tooltip, Skeleton, SafeHtmlPipe], templateUrl: './sub-header.component.html', styleUrl: './sub-header.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index 503a11b31..fd9e0a180 100644 --- a/src/app/shared/constants/index.ts +++ b/src/app/shared/constants/index.ts @@ -3,6 +3,7 @@ export * from './addons-category-options.const'; export * from './addons-tab-options.const'; export * from './input-limits.const'; export * from './input-validation-messages.const'; +export * from './meetings-table.constants'; export * from './remove-nullable.const'; export * from './resource-filters-defaults'; export * from './resource-languages.const'; diff --git a/src/app/features/meetings/constants/meetings-table.constants.ts b/src/app/shared/constants/meetings-table.constants.ts similarity index 62% rename from src/app/features/meetings/constants/meetings-table.constants.ts rename to src/app/shared/constants/meetings-table.constants.ts index 962cb9372..769587a07 100644 --- a/src/app/features/meetings/constants/meetings-table.constants.ts +++ b/src/app/shared/constants/meetings-table.constants.ts @@ -1,6 +1,6 @@ -import { TableParameters } from '@osf/shared/models'; +import { TableParameters } from '@shared/models'; -export const MEETINGS_TABLE_PARAMS: TableParameters = { +export const TABLE_PARAMS: TableParameters = { rows: 10, paginator: true, scrollable: false, diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 82df7cbb1..3aa68b980 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -1,3 +1,4 @@ export * from './addon.mapper'; export * from './filters'; +export * from './institutions'; export * from './resource-card'; diff --git a/src/app/shared/mappers/institutions/general-institution.mapper.ts b/src/app/shared/mappers/institutions/general-institution.mapper.ts new file mode 100644 index 000000000..15dc684b6 --- /dev/null +++ b/src/app/shared/mappers/institutions/general-institution.mapper.ts @@ -0,0 +1,25 @@ +import { FetchInstitutionsJsonApi, GetGeneralInstitutionsResponse, Institution, InstitutionData } from '@shared/models'; + +export class GeneralInstitutionMapper { + static adaptInstitution(data: InstitutionData): Institution { + return { + id: data.id, + type: data.type, + name: data.attributes.name, + description: data.attributes.description, + iri: data.attributes.iri, + rorIri: data.attributes.ror_iri, + iris: data.attributes.iris, + assets: data.attributes.assets, + institutionalRequestAccessEnabled: data.attributes.institutional_request_access_enabled, + logoPath: data.attributes.logo_path, + }; + } + + static adaptInstitutions(response: FetchInstitutionsJsonApi): GetGeneralInstitutionsResponse { + return { + data: response.data.map((institution) => this.adaptInstitution(institution)), + total: response.links.meta.total, + }; + } +} diff --git a/src/app/shared/mappers/institutions/index.ts b/src/app/shared/mappers/institutions/index.ts new file mode 100644 index 000000000..7931a9fad --- /dev/null +++ b/src/app/shared/mappers/institutions/index.ts @@ -0,0 +1,2 @@ +export * from './general-institution.mapper'; +export * from './user-institutions.mapper'; diff --git a/src/app/features/institutions/mappers/institutions.mapper.ts b/src/app/shared/mappers/institutions/user-institutions.mapper.ts similarity index 83% rename from src/app/features/institutions/mappers/institutions.mapper.ts rename to src/app/shared/mappers/institutions/user-institutions.mapper.ts index 4b7751846..c9d12cb8f 100644 --- a/src/app/features/institutions/mappers/institutions.mapper.ts +++ b/src/app/shared/mappers/institutions/user-institutions.mapper.ts @@ -1,6 +1,6 @@ -import { Institution, UserInstitutionGetResponse } from '../models'; +import { Institution, UserInstitutionGetResponse } from '@shared/models'; -export class InstitutionsMapper { +export class UserInstitutionsMapper { static fromResponse(response: UserInstitutionGetResponse): Institution { return { id: response.id, diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index aab430c28..c25c4edec 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -10,6 +10,7 @@ export * from './filter-labels.model'; export * from './filters'; export * from './google-drive-folder.model'; export * from './id-name.model'; +export * from './institutions'; export * from './metadata-field.model'; export * from './nav-item.model'; export * from './node-response.model'; diff --git a/src/app/shared/models/institutions/index.ts b/src/app/shared/models/institutions/index.ts new file mode 100644 index 000000000..64a2ea307 --- /dev/null +++ b/src/app/shared/models/institutions/index.ts @@ -0,0 +1,2 @@ +export * from './institutions.models'; +export * from './institutions-json-api.model'; diff --git a/src/app/shared/models/institutions/institutions-json-api.model.ts b/src/app/shared/models/institutions/institutions-json-api.model.ts new file mode 100644 index 000000000..49017a50c --- /dev/null +++ b/src/app/shared/models/institutions/institutions-json-api.model.ts @@ -0,0 +1,66 @@ +import { Institution, InstitutionAttributes } from '@shared/models'; + +export interface InstitutionRelationships { + nodes: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; + registrations: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; + users: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; +} + +export interface InstitutionLinks { + self: string; + html: string; + iri: string; +} + +export interface InstitutionData { + id: string; + type: string; + attributes: InstitutionAttributes; + relationships: InstitutionRelationships; + links: InstitutionLinks; +} + +export interface InstitutionsResponseLinks { + first: string | null; + last: string | null; + prev: string | null; + next: string | null; + meta: { + total: number; + per_page: number; + }; +} + +export interface FetchInstitutionsJsonApi { + data: InstitutionData[]; + links: InstitutionsResponseLinks; + meta: { + version: string; + }; +} + +export interface GetGeneralInstitutionsResponse { + data: Institution[]; + total: number; +} diff --git a/src/app/features/institutions/models/institutions.models.ts b/src/app/shared/models/institutions/institutions.models.ts similarity index 100% rename from src/app/features/institutions/models/institutions.models.ts rename to src/app/shared/models/institutions/institutions.models.ts diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts index e62e47481..3bbe88ed8 100644 --- a/src/app/shared/services/index.ts +++ b/src/app/shared/services/index.ts @@ -2,6 +2,7 @@ export * from './addons'; export { CustomConfirmationService } from './custom-confirmation.service'; export { FilesService } from './files.service'; export { FiltersOptionsService } from './filters-options.service'; +export { InstitutionsService } from './institutions.service'; export { LoaderService } from './loader.service'; export { ResourceCardService } from './resource-card.service'; export { SearchService } from './search.service'; diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts new file mode 100644 index 000000000..3331b95aa --- /dev/null +++ b/src/app/shared/services/institutions.service.ts @@ -0,0 +1,62 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@core/models'; +import { JsonApiService } from '@core/services'; +import { GeneralInstitutionMapper, UserInstitutionsMapper } from '@shared/mappers'; +import { + FetchInstitutionsJsonApi, + GetGeneralInstitutionsResponse, + Institution, + UserInstitutionGetResponse, +} from '@shared/models'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class InstitutionsService { + private readonly jsonApiService = inject(JsonApiService); + + getInstitutions( + pageNumber: number, + pageSize: number, + searchValue?: string + ): Observable { + const params: Record = {}; + + if (pageNumber) { + params['page'] = pageNumber; + } + + if (pageSize) { + params['page[size]'] = pageSize; + } + + if (searchValue && searchValue.trim()) { + params['filter[name]'] = searchValue.trim(); + } + + return this.jsonApiService + .get(`${environment.apiUrl}/institutions`, params) + .pipe(map((response) => GeneralInstitutionMapper.adaptInstitutions(response))); + } + + getUserInstitutions(): Observable { + const url = `${environment.apiUrl}/users/me/institutions/`; + + return this.jsonApiService + .get>(url) + .pipe(map((response) => response.data.map((item) => UserInstitutionsMapper.fromResponse(item)))); + } + + deleteUserInstitution(id: string, userId: string): Observable { + const payload = { + data: [{ id: id, type: 'institutions' }], + }; + return this.jsonApiService.delete(`${environment.apiUrl}/users/${userId}/relationships/institutions/`, payload); + } +} diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts new file mode 100644 index 000000000..b94bb4458 --- /dev/null +++ b/src/app/shared/stores/index.ts @@ -0,0 +1,2 @@ +export * from './addons'; +export * from './institutions'; diff --git a/src/app/features/institutions/store/index.ts b/src/app/shared/stores/institutions/index.ts similarity index 100% rename from src/app/features/institutions/store/index.ts rename to src/app/shared/stores/institutions/index.ts diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts new file mode 100644 index 000000000..8bcc866b0 --- /dev/null +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -0,0 +1,13 @@ +export class GetUserInstitutions { + static readonly type = '[Institutions] Get User Institutions'; +} + +export class FetchInstitutions { + static readonly type = '[Institutions] Get'; + + constructor( + public pageNumber: number, + public pageSize: number, + public searchValue?: string + ) {} +} diff --git a/src/app/shared/stores/institutions/institutions.model.ts b/src/app/shared/stores/institutions/institutions.model.ts new file mode 100644 index 000000000..aaa403afb --- /dev/null +++ b/src/app/shared/stores/institutions/institutions.model.ts @@ -0,0 +1,7 @@ +import { Institution } from '@shared/models'; +import { AsyncStateWithTotalCount } from '@shared/models/store/async-state-with-total-count.model'; + +export interface InstitutionsStateModel { + userInstitutions: Institution[]; + institutions: AsyncStateWithTotalCount; +} diff --git a/src/app/shared/stores/institutions/institutions.selectors.ts b/src/app/shared/stores/institutions/institutions.selectors.ts new file mode 100644 index 000000000..9414d163d --- /dev/null +++ b/src/app/shared/stores/institutions/institutions.selectors.ts @@ -0,0 +1,26 @@ +import { Selector } from '@ngxs/store'; + +import { InstitutionsStateModel } from './institutions.model'; +import { InstitutionsState } from './institutions.state'; + +export class InstitutionsSelectors { + @Selector([InstitutionsState]) + static getUserInstitutions(state: InstitutionsStateModel) { + return state.userInstitutions; + } + + @Selector([InstitutionsState]) + static getInstitutions(state: InstitutionsStateModel) { + return state.institutions.data; + } + + @Selector([InstitutionsState]) + static isInstitutionsLoading(state: InstitutionsStateModel): boolean { + return state.institutions.isLoading; + } + + @Selector([InstitutionsState]) + static getInstitutionsTotalCount(state: InstitutionsStateModel): number { + return state.institutions.totalCount; + } +} diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts new file mode 100644 index 000000000..08b8c86ff --- /dev/null +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -0,0 +1,74 @@ +import { Action, State, StateContext } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; + +import { catchError, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { InstitutionsService } from '@shared/services'; +import { FetchInstitutions, GetUserInstitutions, InstitutionsStateModel } from '@shared/stores'; + +@State({ + name: 'institutions', + defaults: { + userInstitutions: [], + institutions: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + }, +}) +@Injectable() +export class InstitutionsState { + private readonly institutionsService = inject(InstitutionsService); + + @Action(GetUserInstitutions) + getUserInstitutions(ctx: StateContext) { + return this.institutionsService.getUserInstitutions().pipe( + tap((institutions) => { + ctx.patchState({ + userInstitutions: institutions, + }); + }) + ); + } + + @Action(FetchInstitutions) + getInstitutions(ctx: StateContext, action: FetchInstitutions) { + ctx.patchState({ + institutions: { + data: [], + totalCount: 0, + isLoading: true, + error: null, + }, + }); + + return this.institutionsService.getInstitutions(action.pageNumber, action.pageSize, action.searchValue).pipe( + tap((response) => { + ctx.setState( + patch({ + institutions: patch({ + data: response.data, + totalCount: response.total, + error: null, + isLoading: false, + }), + }) + ); + }), + catchError((error) => { + ctx.patchState({ + institutions: { + ...ctx.getState().institutions, + isLoading: false, + error, + }, + }); + return throwError(() => error); + }) + ); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 92b9d0e20..3872a06a8 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -80,7 +80,8 @@ "contributors": "Contributors", "analytics": "Analytics", "addons": "Add-ons" - } + }, + "institutions": "Institutions" }, "auth": { "common": { @@ -1450,5 +1451,10 @@ "description": "Remember to add metadata and resources to your own work on OSF to make it more discoverable! Learn more in our help guides." }, "stepCount": "{{current}} of {{total}}" + }, + "institutions": { + "title": "Institutions", + "description": "OSF Institutions enhances transparency and increases the visibility of research outputs, accelerating discovery and reuse. Institutional members focus on generating and sharing research, and let OSF Institutions handle the infrastructure.
Read more", + "searchInstitutions": "Search institutions" } }