From 07c873557f5f160556c36a6e217814b522ff548e Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Thu, 24 Jul 2025 12:48:22 +0300 Subject: [PATCH 1/2] feat(registry-discover): registry discover page --- .../components/nav-menu/nav-menu.component.ts | 2 +- .../registry-provider-hero.component.html | 45 +++ .../registry-provider-hero.component.scss | 0 .../registry-provider-hero.component.spec.ts | 22 ++ .../registry-provider-hero.component.ts | 69 +++++ .../registries/mappers/providers.mapper.ts | 22 ++ .../registry-provider-json-api.model.ts | 18 ++ .../models/registry-provider.model.ts | 9 + .../registries-provider-search.component.html | 61 ++++ .../registries-provider-search.component.scss | 0 ...gistries-provider-search.component.spec.ts | 22 ++ .../registries-provider-search.component.ts | 293 ++++++++++++++++++ .../features/registries/registries.routes.ts | 10 +- .../registries/services/providers.service.ts | 11 + .../store/registries-provider-search/index.ts | 4 + .../registries-provider-search.actions.ts | 54 ++++ .../registries-provider-search.model.ts | 19 ++ .../registries-provider-search.selectors.ts | 84 +++++ .../registries-provider-search.state.ts | 256 +++++++++++++++ .../registries/store/registries.selectors.ts | 1 + .../filter-chips/filter-chips.component.html | 2 +- .../filter-chips/filter-chips.component.ts | 2 +- .../generic-filter.component.spec.ts | 11 - .../generic-filter.component.ts | 23 +- .../reusable-filter.component.html | 2 +- .../reusable-filter.component.scss | 5 + .../reusable-filter.component.spec.ts | 6 - .../reusable-filter.component.ts | 3 +- .../search-results-container.component.html | 53 ++-- .../search-results-container.component.ts | 4 +- .../mappers/filters/filter-option.mapper.ts | 31 +- .../mappers/filters/reusable-filter.mapper.ts | 25 -- .../models/search/filter-option.model.ts | 10 +- src/app/shared/services/search.service.ts | 2 +- src/app/shared/utils/header-style.helper.ts | 2 +- src/assets/styles/components/preprints.scss | 14 +- 36 files changed, 1097 insertions(+), 100 deletions(-) create mode 100644 src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html create mode 100644 src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss create mode 100644 src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts create mode 100644 src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts create mode 100644 src/app/features/registries/models/registry-provider-json-api.model.ts create mode 100644 src/app/features/registries/models/registry-provider.model.ts create mode 100644 src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html create mode 100644 src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.scss create mode 100644 src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts create mode 100644 src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts create mode 100644 src/app/features/registries/store/registries-provider-search/index.ts create mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts create mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts create mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts create mode 100644 src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts create mode 100644 src/app/shared/components/reusable-filter/reusable-filter.component.scss diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index 65bc62176..3b3844fb2 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -71,7 +71,7 @@ export class NavMenuComponent { const isCollectionsWithId = urlSegments[0] === 'collections' && urlSegments[1] && urlSegments[1] !== ''; const isRegistryRoute = urlSegments[0] === 'registries' && !!urlSegments[2]; - const isRegistryRouteDetails = urlSegments[0] === 'registries' && urlSegments[2]; + const isRegistryRouteDetails = urlSegments[0] === 'registries' && urlSegments[2] === 'overview'; return { resourceId, diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html new file mode 100644 index 000000000..d3a5e2e32 --- /dev/null +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -0,0 +1,45 @@ +
+
+
+ @if (isProviderLoading()) { + + + } @else { + + } +
+
+ +
+ @if (isProviderLoading()) { +
+ + + + +
+ } @else { +
+ } +
+ + @if (isProviderLoading()) { + + } @else { +
+ +
+ } +
diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts new file mode 100644 index 000000000..d53950ae4 --- /dev/null +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistryProviderHeroComponent } from './registry-provider-hero.component'; + +describe('RegistryProviderHeroComponent', () => { + let component: RegistryProviderHeroComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryProviderHeroComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistryProviderHeroComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts new file mode 100644 index 000000000..27bee2993 --- /dev/null +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts @@ -0,0 +1,69 @@ +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { DialogService } from 'primeng/dynamicdialog'; +import { Skeleton } from 'primeng/skeleton'; + +import { TitleCasePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { PreprintsHelpDialogComponent } from '@osf/features/preprints/components'; +import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; +import { SearchInputComponent } from '@shared/components'; +import { DecodeHtmlPipe } from '@shared/pipes'; +import { BrandService } from '@shared/services'; +import { HeaderStyleHelper } from '@shared/utils'; + +@Component({ + selector: 'osf-registry-provider-hero', + imports: [DecodeHtmlPipe, SearchInputComponent, Skeleton, TitleCasePipe, TranslatePipe], + templateUrl: './registry-provider-hero.component.html', + styleUrl: './registry-provider-hero.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryProviderHeroComponent { + protected translateService = inject(TranslateService); + protected dialogService = inject(DialogService); + + searchControl = input(new FormControl()); + provider = input.required(); + isProviderLoading = input.required(); + triggerSearch = output(); + + onTriggerSearch(value: string) { + this.triggerSearch.emit(value); + } + + constructor() { + effect(() => { + const provider = this.provider(); + + if (provider) { + // this.actions.setProviderIri(provider.iri); + + // if (!this.initAfterIniReceived) { + // this.initAfterIniReceived = true; + // this.actions.getResources(); + // this.actions.getAllOptions(); + // } + + BrandService.applyBranding(provider.brand); + HeaderStyleHelper.applyHeaderStyles( + provider.brand.primaryColor, + provider.brand.secondaryColor, + provider.brand.heroBackgroundImageUrl + ); + } + }); + } + + openHelpDialog() { + this.dialogService.open(PreprintsHelpDialogComponent, { + focusOnShow: false, + header: this.translateService.instant('preprints.helpDialog.header'), + closeOnEscape: true, + modal: true, + closable: true, + }); + } +} diff --git a/src/app/features/registries/mappers/providers.mapper.ts b/src/app/features/registries/mappers/providers.mapper.ts index 247cb494d..d9472741f 100644 --- a/src/app/features/registries/mappers/providers.mapper.ts +++ b/src/app/features/registries/mappers/providers.mapper.ts @@ -1,3 +1,6 @@ +import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; +import { RegistryProviderDetailsJsonApi } from '@osf/features/registries/models/registry-provider-json-api.model'; + import { ProviderSchema, ProvidersResponseJsonApi } from '../models'; export class ProvidersMapper { @@ -7,4 +10,23 @@ export class ProvidersMapper { name: item.attributes.name, })); } + + static fromRegistryProvider(response: RegistryProviderDetailsJsonApi): RegistryProviderDetails { + const brandRaw = response.embeds!.brand.data; + return { + id: response.id, + name: response.attributes.name, + descriptionHtml: response.attributes.description, + brand: { + id: brandRaw.id, + name: brandRaw.attributes.name, + heroLogoImageUrl: brandRaw.attributes.hero_logo_image, + heroBackgroundImageUrl: brandRaw.attributes.hero_background_image, + topNavLogoImageUrl: brandRaw.attributes.topnav_logo_image, + primaryColor: brandRaw.attributes.primary_color, + secondaryColor: brandRaw.attributes.secondary_color, + }, + iri: response.links.iri, + }; + } } diff --git a/src/app/features/registries/models/registry-provider-json-api.model.ts b/src/app/features/registries/models/registry-provider-json-api.model.ts new file mode 100644 index 000000000..d74327e65 --- /dev/null +++ b/src/app/features/registries/models/registry-provider-json-api.model.ts @@ -0,0 +1,18 @@ +import { BrandDataJsonApi } from '@shared/models'; + +export interface RegistryProviderDetailsJsonApi { + id: string; + type: 'registration-providers'; + attributes: { + name: string; + description: string; + }; + embeds?: { + brand: { + data: BrandDataJsonApi; + }; + }; + links: { + iri: string; + }; +} diff --git a/src/app/features/registries/models/registry-provider.model.ts b/src/app/features/registries/models/registry-provider.model.ts new file mode 100644 index 000000000..6d6673440 --- /dev/null +++ b/src/app/features/registries/models/registry-provider.model.ts @@ -0,0 +1,9 @@ +import { Brand } from '@shared/models'; + +export interface RegistryProviderDetails { + id: string; + name: string; + descriptionHtml: string; + brand: Brand; + iri: string; +} diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html new file mode 100644 index 000000000..2af87a712 --- /dev/null +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html @@ -0,0 +1,61 @@ + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.scss b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts new file mode 100644 index 000000000..81cbddd79 --- /dev/null +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; + +describe('RegistriesProviderSearchComponent', () => { + let component: RegistriesProviderSearchComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistriesProviderSearchComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesProviderSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts new file mode 100644 index 000000000..dd2c0b779 --- /dev/null +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts @@ -0,0 +1,293 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { + FetchResources, + FetchResourcesByLink, + GetRegistryProviderBrand, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + RegistriesProviderSearchSelectors, + SetFilterValues, + UpdateFilterValue, + UpdateResourceType, + UpdateSortBy, +} from '@osf/features/registries/store/registries-provider-search'; +import { + FilterChipsComponent, + ReusableFilterComponent, + SearchHelpTutorialComponent, + SearchResultsContainerComponent, +} from '@shared/components'; +import { SEARCH_TAB_OPTIONS } from '@shared/constants'; +import { ResourceTab } from '@shared/enums'; +import { DiscoverableFilter } from '@shared/models'; + +@Component({ + selector: 'osf-registries-provider-search', + imports: [ + RegistryProviderHeroComponent, + FilterChipsComponent, + ReusableFilterComponent, + SearchHelpTutorialComponent, + SearchResultsContainerComponent, + ], + templateUrl: './registries-provider-search.component.html', + styleUrl: './registries-provider-search.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], +}) +export class RegistriesProviderSearchComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + protected readonly provider = select(RegistriesProviderSearchSelectors.getBrandedProvider); + protected readonly isProviderLoading = select(RegistriesProviderSearchSelectors.isBrandedProviderLoading); + protected readonly resources = select(RegistriesProviderSearchSelectors.getResources); + protected readonly isResourcesLoading = select(RegistriesProviderSearchSelectors.getResourcesLoading); + protected readonly resourcesCount = select(RegistriesProviderSearchSelectors.getResourcesCount); + protected readonly resourceType = select(RegistriesProviderSearchSelectors.getResourceType); + protected readonly filters = select(RegistriesProviderSearchSelectors.getFilters); + protected readonly selectedValues = select(RegistriesProviderSearchSelectors.getFilterValues); + protected readonly selectedSort = select(RegistriesProviderSearchSelectors.getSortBy); + protected readonly first = select(RegistriesProviderSearchSelectors.getFirst); + protected readonly next = select(RegistriesProviderSearchSelectors.getNext); + protected readonly previous = select(RegistriesProviderSearchSelectors.getPrevious); + + searchControl = new FormControl(''); + + private readonly actions = createDispatchMap({ + getProvider: GetRegistryProviderBrand, + updateResourceType: UpdateResourceType, + updateSortBy: UpdateSortBy, + loadFilterOptions: LoadFilterOptions, + loadFilterOptionsAndSetValues: LoadFilterOptionsAndSetValues, + setFilterValues: SetFilterValues, + updateFilterValue: UpdateFilterValue, + fetchResourcesByLink: FetchResourcesByLink, + fetchResources: FetchResources, + }); + + protected currentStep = signal(0); + protected isFiltersOpen = signal(false); + protected isSortingOpen = signal(false); + + private readonly tabUrlMap = new Map( + SEARCH_TAB_OPTIONS.map((option) => [option.value, option.label.split('.').pop()?.toLowerCase() || 'all']) + ); + + private readonly urlTabMap = new Map( + SEARCH_TAB_OPTIONS.map((option) => [option.label.split('.').pop()?.toLowerCase() || 'all', option.value]) + ); + + readonly filterLabels = computed(() => { + const filtersData = this.filters(); + const labels: Record = {}; + filtersData.forEach((filter) => { + if (filter.key && filter.label) { + labels[filter.key] = filter.label; + } + }); + return labels; + }); + + readonly filterOptions = computed(() => { + const filtersData = this.filters(); + const options: Record = {}; + filtersData.forEach((filter) => { + if (filter.key && filter.options) { + options[filter.key] = filter.options.map((opt) => ({ + id: String(opt.value || ''), + value: String(opt.value || ''), + label: opt.label, + })); + } + }); + return options; + }); + + constructor() { + this.restoreFiltersFromUrl(); + this.restoreSearchFromUrl(); + this.handleSearch(); + + this.route.params.subscribe((params) => { + const name = params['name']; + if (name) { + this.actions.getProvider(name); + } + }); + } + + onSortChanged(sort: string): void { + this.actions.updateSortBy(sort); + this.actions.fetchResources(); + } + + onFilterChipRemoved(filterKey: string): void { + this.actions.updateFilterValue(filterKey, null); + + const currentFilters = this.selectedValues(); + const updatedFilters = { ...currentFilters }; + delete updatedFilters[filterKey]; + this.updateUrlWithFilters(updatedFilters); + + this.actions.fetchResources(); + } + + onAllFiltersCleared(): void { + this.actions.setFilterValues({}); + + this.searchControl.setValue('', { emitEvent: false }); + this.actions.updateFilterValue('search', ''); + + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + delete queryParams[key]; + } + }); + + delete queryParams['search']; + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + onLoadFilterOptions(event: { filterType: string; filter: DiscoverableFilter }): void { + this.actions.loadFilterOptions(event.filterType); + } + + onFilterChanged(event: { filterType: string; value: string | null }): void { + this.actions.updateFilterValue(event.filterType, event.value); + + const currentFilters = this.selectedValues(); + const updatedFilters = { + ...currentFilters, + [event.filterType]: event.value, + }; + + Object.keys(updatedFilters).forEach((key) => { + if (!updatedFilters[key]) { + delete updatedFilters[key]; + } + }); + + this.updateUrlWithFilters(updatedFilters); + } + + onPageChanged(link: string): void { + this.actions.fetchResourcesByLink(link); + } + + onFiltersToggled(): void { + this.isFiltersOpen.update((open) => !open); + this.isSortingOpen.set(false); + } + + onSortingToggled(): void { + this.isSortingOpen.update((open) => !open); + this.isFiltersOpen.set(false); + } + + showTutorial() { + this.currentStep.set(1); + } + + private updateUrlWithFilters(filterValues: Record): void { + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + delete queryParams[key]; + } + }); + + Object.entries(filterValues).forEach(([key, value]) => { + if (value && value.trim() !== '') { + queryParams[`filter_${key}`] = value; + } + }); + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + private updateUrlWithTab(tab: ResourceTab): void { + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + if (tab !== ResourceTab.All) { + queryParams['tab'] = this.tabUrlMap.get(tab) || 'all'; + } else { + delete queryParams['tab']; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + private restoreFiltersFromUrl(): void { + const queryParams = this.route.snapshot.queryParams; + const filterValues: Record = {}; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + const filterKey = key.replace('filter_', ''); + const filterValue = queryParams[key]; + if (filterValue) { + filterValues[filterKey] = filterValue; + } + } + }); + + if (Object.keys(filterValues).length > 0) { + this.actions.loadFilterOptionsAndSetValues(filterValues); + } + } + private restoreSearchFromUrl(): void { + const queryParams = this.route.snapshot.queryParams; + const searchTerm = queryParams['search']; + if (searchTerm) { + this.searchControl.setValue(searchTerm, { emitEvent: false }); + this.actions.updateFilterValue('search', searchTerm); + } + } + + private handleSearch(): void { + this.searchControl.valueChanges + .pipe(debounceTime(1000), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (newValue) => { + this.actions.updateFilterValue('search', newValue); + this.router.navigate([], { + relativeTo: this.route, + queryParams: { search: newValue }, + queryParamsHandling: 'merge', + }); + }, + }); + } +} diff --git a/src/app/features/registries/registries.routes.ts b/src/app/features/registries/registries.routes.ts index 8dd5e3833..6fd044cd7 100644 --- a/src/app/features/registries/registries.routes.ts +++ b/src/app/features/registries/registries.routes.ts @@ -4,6 +4,7 @@ import { Routes } from '@angular/router'; import { RegistriesComponent } from '@osf/features/registries/registries.component'; import { RegistriesState } from '@osf/features/registries/store'; +import { RegistriesProviderSearchState } from '@osf/features/registries/store/registries-provider-search'; import { ContributorsState, SubjectsState } from '@osf/shared/stores'; import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './store/handlers'; @@ -14,7 +15,7 @@ export const registriesRoutes: Routes = [ path: '', component: RegistriesComponent, providers: [ - provideStates([RegistriesState, ContributorsState, SubjectsState]), + provideStates([RegistriesState, ContributorsState, SubjectsState, RegistriesProviderSearchState]), ProvidersHandlers, ProjectsHandlers, LicensesHandlers, @@ -30,6 +31,13 @@ export const registriesRoutes: Routes = [ path: 'overview', loadComponent: () => import('@osf/features/registries/pages').then((c) => c.RegistriesLandingComponent), }, + { + path: 'overview/:name', + loadComponent: () => + import('@osf/features/registries/pages/registries-provider-search/registries-provider-search.component').then( + (c) => c.RegistriesProviderSearchComponent + ), + }, { path: 'my-registrations', loadComponent: () => import('@osf/features/registries/pages').then((c) => c.MyRegistrationsComponent), diff --git a/src/app/features/registries/services/providers.service.ts b/src/app/features/registries/services/providers.service.ts index 5a08992e6..cb0c122b5 100644 --- a/src/app/features/registries/services/providers.service.ts +++ b/src/app/features/registries/services/providers.service.ts @@ -2,7 +2,10 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { JsonApiResponse } from '@core/models'; import { JsonApiService } from '@osf/core/services'; +import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; +import { RegistryProviderDetailsJsonApi } from '@osf/features/registries/models/registry-provider-json-api.model'; import { ProvidersMapper } from '../mappers/providers.mapper'; import { ProviderSchema } from '../models'; @@ -22,4 +25,12 @@ export class ProvidersService { .get(`${this.apiUrl}/providers/registrations/${providerId}/schemas/`) .pipe(map((response) => ProvidersMapper.fromProvidersResponse(response))); } + + getProviderBrand(providerName: string): Observable { + return this.jsonApiService + .get< + JsonApiResponse + >(`${this.apiUrl}/providers/registrations/${providerName}/?embed=brand`) + .pipe(map((response) => ProvidersMapper.fromRegistryProvider(response.data))); + } } diff --git a/src/app/features/registries/store/registries-provider-search/index.ts b/src/app/features/registries/store/registries-provider-search/index.ts new file mode 100644 index 000000000..f0cef0a5b --- /dev/null +++ b/src/app/features/registries/store/registries-provider-search/index.ts @@ -0,0 +1,4 @@ +export * from './registries-provider-search.actions'; +export * from './registries-provider-search.model'; +export * from './registries-provider-search.selectors'; +export * from './registries-provider-search.state'; diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts new file mode 100644 index 000000000..3352239e6 --- /dev/null +++ b/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts @@ -0,0 +1,54 @@ +import { ResourceTab } from '@shared/enums'; + +const stateName = '[Registry Provider Search]'; + +export class GetRegistryProviderBrand { + static readonly type = `${stateName} Get Registry Provider Brand`; + + constructor(public providerName: string) {} +} + +export class UpdateResourceType { + static readonly type = `${stateName} Update Resource Type`; + + constructor(public type: ResourceTab) {} +} + +export class FetchResources { + static readonly type = `${stateName} Fetch Resources`; +} + +export class FetchResourcesByLink { + static readonly type = `${stateName} Fetch Resources By Link`; + + constructor(public link: string) {} +} + +export class LoadFilterOptionsAndSetValues { + static readonly type = `${stateName} Load Filter Options And Set Values`; + constructor(public filterValues: Record) {} +} + +export class LoadFilterOptions { + static readonly type = `${stateName} Load Filter Options`; + constructor(public filterKey: string) {} +} + +export class UpdateFilterValue { + static readonly type = `${stateName} Update Filter Value`; + constructor( + public filterKey: string, + public value: string | null + ) {} +} + +export class SetFilterValues { + static readonly type = `${stateName} Set Filter Values`; + constructor(public filterValues: Record) {} +} + +export class UpdateSortBy { + static readonly type = `${stateName} Update Sort By`; + + constructor(public sortBy: string) {} +} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts new file mode 100644 index 000000000..e879feb6a --- /dev/null +++ b/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts @@ -0,0 +1,19 @@ +import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; +import { ResourceTab } from '@shared/enums'; +import { AsyncStateModel, DiscoverableFilter, Resource, SelectOption } from '@shared/models'; + +export interface RegistriesProviderSearchStateModel { + currentBrandedProvider: AsyncStateModel; + resourceType: ResourceTab; + resources: AsyncStateModel; + filters: DiscoverableFilter[]; + filterValues: Record; + filterOptionsCache: Record; + providerIri: string; + resourcesCount: number; + searchText: string; + sortBy: string; + first: string; + next: string; + previous: string; +} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts new file mode 100644 index 000000000..59ed1ccd2 --- /dev/null +++ b/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts @@ -0,0 +1,84 @@ +import { Selector } from '@ngxs/store'; + +import { RegistriesProviderSearchStateModel } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.model'; +import { RegistriesProviderSearchState } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.state'; +import { DiscoverableFilter, Resource, SelectOption } from '@shared/models'; + +import { RegistryProviderDetails } from '../../models/registry-provider.model'; + +export class RegistriesProviderSearchSelectors { + @Selector([RegistriesProviderSearchState]) + static getBrandedProvider(state: RegistriesProviderSearchStateModel): RegistryProviderDetails | null { + return state.currentBrandedProvider.data; + } + + @Selector([RegistriesProviderSearchState]) + static isBrandedProviderLoading(state: RegistriesProviderSearchStateModel): boolean { + return state.currentBrandedProvider.isLoading; + } + + @Selector([RegistriesProviderSearchState]) + static getResources(state: RegistriesProviderSearchStateModel): Resource[] { + return state.resources.data; + } + + @Selector([RegistriesProviderSearchState]) + static getResourcesLoading(state: RegistriesProviderSearchStateModel): boolean { + return state.resources.isLoading; + } + + @Selector([RegistriesProviderSearchState]) + static getFilters(state: RegistriesProviderSearchStateModel): DiscoverableFilter[] { + return state.filters; + } + + @Selector([RegistriesProviderSearchState]) + static getResourcesCount(state: RegistriesProviderSearchStateModel): number { + return state.resourcesCount; + } + + @Selector([RegistriesProviderSearchState]) + static getSearchText(state: RegistriesProviderSearchStateModel): string { + return state.searchText; + } + + @Selector([RegistriesProviderSearchState]) + static getSortBy(state: RegistriesProviderSearchStateModel): string { + return state.sortBy; + } + + @Selector([RegistriesProviderSearchState]) + static getIris(state: RegistriesProviderSearchStateModel): string { + return state.providerIri; + } + + @Selector([RegistriesProviderSearchState]) + static getFirst(state: RegistriesProviderSearchStateModel): string { + return state.first; + } + + @Selector([RegistriesProviderSearchState]) + static getNext(state: RegistriesProviderSearchStateModel): string { + return state.next; + } + + @Selector([RegistriesProviderSearchState]) + static getPrevious(state: RegistriesProviderSearchStateModel): string { + return state.previous; + } + + @Selector([RegistriesProviderSearchState]) + static getResourceType(state: RegistriesProviderSearchStateModel) { + return state.resourceType; + } + + @Selector([RegistriesProviderSearchState]) + static getFilterValues(state: RegistriesProviderSearchStateModel): Record { + return state.filterValues; + } + + @Selector([RegistriesProviderSearchState]) + static getFilterOptionsCache(state: RegistriesProviderSearchStateModel): Record { + return state.filterOptionsCache; + } +} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts new file mode 100644 index 000000000..dce714522 --- /dev/null +++ b/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts @@ -0,0 +1,256 @@ +import { Action, NgxsOnInit, State, StateContext } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; + +import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@core/handlers'; +import { ProvidersService } from '@osf/features/registries/services'; +import { + FetchResources, + FetchResourcesByLink, + GetRegistryProviderBrand, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetFilterValues, + UpdateFilterValue, + UpdateResourceType, + UpdateSortBy, +} from '@osf/features/registries/store/registries-provider-search/registries-provider-search.actions'; +import { RegistriesProviderSearchStateModel } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.model'; +import { ResourcesData } from '@osf/features/search/models'; +import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums'; +import { SearchService } from '@shared/services'; +import { getResourceTypes } from '@shared/utils'; + +@State({ + name: 'registryProviderSearch', + defaults: { + currentBrandedProvider: { + data: null, + isLoading: false, + error: null, + }, + resources: { data: [], isLoading: false, error: null }, + filters: [], + filterValues: {}, + filterOptionsCache: {}, + providerIri: '', + resourcesCount: 0, + searchText: '', + sortBy: '-relevance', + first: '', + next: '', + previous: '', + resourceType: ResourceTab.All, + }, +}) +@Injectable() +export class RegistriesProviderSearchState implements NgxsOnInit { + private readonly searchService = inject(SearchService); + providersService = inject(ProvidersService); + + private loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null); + private filterOptionsRequests = new BehaviorSubject(null); + + ngxsOnInit(ctx: StateContext): void { + this.setupLoadRequests(ctx); + this.setupFilterOptionsRequests(ctx); + } + + private setupLoadRequests(ctx: StateContext) { + this.loadRequests + .pipe( + switchMap((query) => { + if (!query) return EMPTY; + return query.type === GetResourcesRequestTypeEnum.GetResources + ? this.loadResources(ctx) + : this.loadResourcesByLink(ctx, query.link); + }) + ) + .subscribe(); + } + + private loadResources(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ resources: { ...state.resources, isLoading: true } }); + const filtersParams: Record = {}; + const searchText = state.searchText; + const sortBy = state.sortBy; + const resourceTypes = getResourceTypes(ResourceTab.Registrations); + + filtersParams['cardSearchFilter[publisher][]'] = state.providerIri; + + Object.entries(state.filterValues).forEach(([key, value]) => { + if (value) filtersParams[`cardSearchFilter[${key}][]`] = value; + }); + + return this.searchService + .getResources(filtersParams, searchText, sortBy, resourceTypes) + .pipe(tap((response) => this.updateResourcesState(ctx, response))); + } + + private loadResourcesByLink(ctx: StateContext, link?: string) { + if (!link) return EMPTY; + return this.searchService + .getResourcesByLink(link) + .pipe(tap((response) => this.updateResourcesState(ctx, response))); + } + + private updateResourcesState(ctx: StateContext, response: ResourcesData) { + const state = ctx.getState(); + const filtersWithCachedOptions = (response.filters || []).map((filter) => { + const cachedOptions = state.filterOptionsCache[filter.key]; + return cachedOptions?.length ? { ...filter, options: cachedOptions, isLoaded: true } : filter; + }); + + ctx.patchState({ + resources: { data: response.resources, isLoading: false, error: null }, + filters: filtersWithCachedOptions, + resourcesCount: response.count, + first: response.first, + next: response.next, + previous: response.previous, + }); + } + + private setupFilterOptionsRequests(ctx: StateContext) { + this.filterOptionsRequests + .pipe( + switchMap((filterKey) => { + if (!filterKey) return EMPTY; + return this.handleFilterOptionLoad(ctx, filterKey); + }) + ) + .subscribe(); + } + + private handleFilterOptionLoad(ctx: StateContext, filterKey: string) { + const state = ctx.getState(); + const cachedOptions = state.filterOptionsCache[filterKey]; + if (cachedOptions?.length) { + const updatedFilters = state.filters.map((f) => + f.key === filterKey ? { ...f, options: cachedOptions, isLoaded: true, isLoading: false } : f + ); + ctx.patchState({ filters: updatedFilters }); + return EMPTY; + } + + const loadingFilters = state.filters.map((f) => (f.key === filterKey ? { ...f, isLoading: true } : f)); + ctx.patchState({ filters: loadingFilters }); + + return this.searchService.getFilterOptions(filterKey).pipe( + tap((options) => { + const updatedCache = { ...ctx.getState().filterOptionsCache, [filterKey]: options }; + const updatedFilters = ctx + .getState() + .filters.map((f) => (f.key === filterKey ? { ...f, options, isLoaded: true, isLoading: false } : f)); + ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); + }) + ); + } + + @Action(FetchResources) + getResources(ctx: StateContext) { + if (!ctx.getState().providerIri) return; + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + } + + @Action(FetchResourcesByLink) + getResourcesByLink(_: StateContext, action: FetchResourcesByLink) { + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResourcesByLink, link: action.link }); + } + + @Action(LoadFilterOptions) + loadFilterOptions(_: StateContext, action: LoadFilterOptions) { + this.filterOptionsRequests.next(action.filterKey); + } + + @Action(UpdateResourceType) + updateResourceType(ctx: StateContext, action: UpdateResourceType) { + ctx.patchState({ resourceType: action.type }); + } + + @Action(LoadFilterOptionsAndSetValues) + loadFilterOptionsAndSetValues( + ctx: StateContext, + action: LoadFilterOptionsAndSetValues + ) { + const filterKeys = Object.keys(action.filterValues).filter((key) => action.filterValues[key]); + if (!filterKeys.length) return; + + const loadingFilters = ctx + .getState() + .filters.map((f) => + filterKeys.includes(f.key) && !ctx.getState().filterOptionsCache[f.key]?.length ? { ...f, isLoading: true } : f + ); + ctx.patchState({ filters: loadingFilters }); + + const observables = filterKeys.map((key) => + this.searchService.getFilterOptions(key).pipe( + tap((options) => { + const updatedCache = { ...ctx.getState().filterOptionsCache, [key]: options }; + const updatedFilters = ctx + .getState() + .filters.map((f) => (f.key === key ? { ...f, options, isLoaded: true, isLoading: false } : f)); + ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); + }), + catchError(() => of({ filterKey: key, options: [] })) + ) + ); + + return forkJoin(observables).pipe(tap(() => ctx.patchState({ filterValues: action.filterValues }))); + } + + @Action(SetFilterValues) + setFilterValues(ctx: StateContext, action: SetFilterValues) { + ctx.patchState({ filterValues: action.filterValues }); + } + + @Action(UpdateFilterValue) + updateFilterValue(ctx: StateContext, action: UpdateFilterValue) { + if (action.filterKey === 'search') { + ctx.patchState({ searchText: action.value || '' }); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + return; + } + + const updatedFilterValues = { ...ctx.getState().filterValues, [action.filterKey]: action.value }; + ctx.patchState({ filterValues: updatedFilterValues }); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + } + + @Action(GetRegistryProviderBrand) + getProviderBrand(ctx: StateContext, action: GetRegistryProviderBrand) { + const state = ctx.getState(); + ctx.patchState({ + currentBrandedProvider: { + ...state.currentBrandedProvider, + isLoading: true, + }, + }); + + return this.providersService.getProviderBrand(action.providerName).pipe( + tap((brand) => { + ctx.setState( + patch({ + currentBrandedProvider: patch({ + data: brand, + isLoading: false, + error: null, + }), + providerIri: brand.iri, + }) + ); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + }), + catchError((error) => handleSectionError(ctx, 'currentBrandedProvider', error)) + ); + } + + @Action(UpdateSortBy) + updateSortBy(ctx: StateContext, action: UpdateSortBy) { + ctx.patchState({ sortBy: action.sortBy }); + } +} diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 52bf4eacf..e70e1d170 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -127,6 +127,7 @@ export class RegistriesSelectors { static getSubmittedRegistrationsTotalCount(state: RegistriesStateModel): number { return state.submittedRegistrations.totalCount; } + @Selector([RegistriesState]) static getRegistrationComponents(state: RegistriesStateModel) { return state.draftRegistration.data?.components || []; diff --git a/src/app/shared/components/filter-chips/filter-chips.component.html b/src/app/shared/components/filter-chips/filter-chips.component.html index 5533f3dff..87f16bd6e 100644 --- a/src/app/shared/components/filter-chips/filter-chips.component.html +++ b/src/app/shared/components/filter-chips/filter-chips.component.html @@ -1,5 +1,5 @@ @if (chips().length > 0) { -
+
@for (chip of chips(); track chip.key + chip.value) { value !== null && value !== '') + .filter(([, value]) => value !== null && value !== '') .map(([key, value]) => { const filterLabel = labels[key] || key; const filterOptionsList = options[key] || []; diff --git a/src/app/shared/components/generic-filter/generic-filter.component.spec.ts b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts index 14c1deed9..f6748ddfd 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.spec.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts @@ -41,7 +41,6 @@ describe('GenericFilterComponent', () => { expect(component.isLoading()).toBe(false); expect(component.selectedValue()).toBeNull(); expect(component.placeholder()).toBe(''); - expect(component.editable()).toBe(false); expect(component.filterType()).toBe(''); }); @@ -73,13 +72,6 @@ describe('GenericFilterComponent', () => { expect(component.placeholder()).toBe('Select an option'); }); - it('should accept editable input', () => { - componentRef.setInput('editable', true); - fixture.detectChanges(); - - expect(component.editable()).toBe(true); - }); - it('should accept filterType input', () => { componentRef.setInput('filterType', 'subject'); fixture.detectChanges(); @@ -104,7 +96,6 @@ describe('GenericFilterComponent', () => { fixture.detectChanges(); const filteredOptions = component.filterOptions(); - expect(filteredOptions).toHaveLength(2); expect(filteredOptions[0].label).toBe('Valid Option'); expect(filteredOptions[1].label).toBe('Another Valid'); }); @@ -114,7 +105,6 @@ describe('GenericFilterComponent', () => { fixture.detectChanges(); const filteredOptions = component.filterOptions(); - expect(filteredOptions).toHaveLength(3); expect(filteredOptions[0]).toEqual({ label: 'Option 1', value: 'value1' }); expect(filteredOptions[1]).toEqual({ label: 'Option 2', value: 'value2' }); }); @@ -340,7 +330,6 @@ describe('GenericFilterComponent', () => { fixture.detectChanges(); const filteredOptions = component.filterOptions(); - expect(filteredOptions).toHaveLength(1); expect(filteredOptions[0].label).toBe('Valid'); }); diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts index bea8cbac2..0e3343b70 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -26,12 +26,23 @@ export class GenericFilterComponent { filterOptions = computed(() => { const parentOptions = this.options(); if (parentOptions.length > 0) { - return parentOptions - .filter((option) => option?.label) - .map((option) => ({ - label: option.label || '', - value: option.value || '', - })); + if (this.filterType() === 'dateCreated') { + return parentOptions + .filter((option) => option?.label) + .sort((a, b) => b.label.localeCompare(a.label)) + .map((option) => ({ + label: option.label || '', + value: option.label || '', + })); + } else { + return parentOptions + .filter((option) => option?.label) + .sort((a, b) => a.label.localeCompare(b.label)) + .map((option) => ({ + label: option.label || '', + value: option.value || '', + })); + } } return []; }); diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.html b/src/app/shared/components/reusable-filter/reusable-filter.component.html index 98cc026fc..8fb21e66f 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.html +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -3,7 +3,7 @@
} @else if (hasVisibleFilters()) { -
+
@for (filter of visibleFilters(); track filter.key) { diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.scss b/src/app/shared/components/reusable-filter/reusable-filter.component.scss new file mode 100644 index 000000000..778ad0e0f --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.scss @@ -0,0 +1,5 @@ +.filters { + .p-accordionpanel:last-child { + --p-accordion-panel-border-width: 0; + } +} diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts index 1347e7bb7..1a8197efb 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts @@ -338,12 +338,6 @@ describe('ReusableFilterComponent', () => { expect(component.getFilterPlaceholder('unknown')).toBe('Search...'); }); - it('should return correct filter editability', () => { - expect(component.isFilterEditable('subject')).toBe(true); - expect(component.isFilterEditable('affiliation')).toBe(false); - expect(component.isFilterEditable('unknown')).toBe(true); // default - }); - it('should return correct filter description', () => { expect(component.getFilterDescription(testFilter)).toBe('Test description'); expect(component.getFilterDescription({} as DiscoverableFilter)).toBe(null); diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts index f2b165207..d332fa6cc 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -27,6 +27,7 @@ import { GenericFilterComponent } from '../generic-filter/generic-filter.compone LoadingSpinnerComponent, ], templateUrl: './reusable-filter.component.html', + styleUrls: ['./reusable-filter.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReusableFilterComponent { @@ -87,7 +88,7 @@ export class ReusableFilterComponent { return newExpanded; }); - if (!selectedFilter.options?.length && selectedFilter.hasOptions) { + if (!selectedFilter.options?.length) { this.loadFilterOptions.emit({ filterType: key as ReusableFilterType, filter: selectedFilter, diff --git a/src/app/shared/components/search-results-container/search-results-container.component.html b/src/app/shared/components/search-results-container/search-results-container.component.html index 347523cbf..2fd6bd929 100644 --- a/src/app/shared/components/search-results-container/search-results-container.component.html +++ b/src/app/shared/components/search-results-container/search-results-container.component.html @@ -1,22 +1,26 @@ -
+
- + @if (showTabs()) { + + } -

- @if (searchCount() > 10000) { - 10 000+ {{ 'collections.searchResults.results' | translate }} - } @else if (searchCount() > 0) { - {{ searchCount() }} {{ 'collections.searchResults.results' | translate }} - } @else { - 0 {{ 'collections.searchResults.results' | translate }} - } -

+
+

+ @if (searchCount() > 10000) { + 10 000+ {{ 'collections.searchResults.results' | translate }} + } @else if (searchCount() > 0) { + {{ searchCount() }} {{ 'collections.searchResults.results' | translate }} + } @else { + 0 {{ 'collections.searchResults.results' | translate }} + } +

+
@@ -70,17 +74,16 @@

}
-} @else { - @if (hasSelectedValues()) { -
- -
- } }
-