diff --git a/package.json b/package.json index 7ed7471f5..f753a31bd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "private": true, "dependencies": { + "@angular/animations": "^19.2.0", "@angular/cdk": "^19.2.1", "@angular/cli": "^19.2.0", "@angular/common": "^19.2.0", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 423018794..51cadfd08 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -4,10 +4,17 @@ import { AuthState } from '@core/store/auth'; import { HomeState } from 'src/app/features/home/store'; import { TokensState } from '@core/store/settings'; import { AddonsState } from '@core/store/settings/addons'; +import { SearchState } from '@osf/features/search/store'; @NgModule({ imports: [ - NgxsModule.forRoot([AuthState, TokensState, AddonsState, HomeState]), + NgxsModule.forRoot([ + AuthState, + TokensState, + AddonsState, + HomeState, + SearchState, + ]), ], }) export class AppModule {} diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d5f9d31bc..92eefa145 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { provideStates } from '@ngxs/store'; import { ResourceFiltersState } from '@shared/components/resources/resource-filters/store/resource-filters.state'; +import { ResourceFiltersOptionsState } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.state'; export const routes: Routes = [ { @@ -140,7 +141,9 @@ export const routes: Routes = [ import('./features/search/search.component').then( (mod) => mod.SearchComponent, ), - providers: [provideStates([ResourceFiltersState])], + providers: [ + provideStates([ResourceFiltersState, ResourceFiltersOptionsState]), + ], }, ], }, diff --git a/src/app/core/components/header/header.component.html b/src/app/core/components/header/header.component.html index f73896f19..7508423be 100644 --- a/src/app/core/components/header/header.component.html +++ b/src/app/core/components/header/header.component.html @@ -1,2 +1,2 @@ - + {{ authButtonText() }} diff --git a/src/app/core/helpers/ngxs-states.constant.ts b/src/app/core/helpers/ngxs-states.constant.ts index 404b90898..c0d346532 100644 --- a/src/app/core/helpers/ngxs-states.constant.ts +++ b/src/app/core/helpers/ngxs-states.constant.ts @@ -3,6 +3,7 @@ import { TokensState } from '@core/store/settings'; import { AddonsState } from '@core/store/settings/addons'; import { UserState } from '@core/store/user'; import { HomeState } from 'src/app/features/home/store'; +import { SearchState } from '@osf/features/search/store'; export const STATES = [ AuthState, @@ -10,4 +11,5 @@ export const STATES = [ AddonsState, UserState, HomeState, + SearchState, ]; diff --git a/src/app/core/services/json-api/json-api.entity.ts b/src/app/core/services/json-api/json-api.entity.ts index d81c40cd5..f483ff96a 100644 --- a/src/app/core/services/json-api/json-api.entity.ts +++ b/src/app/core/services/json-api/json-api.entity.ts @@ -3,8 +3,10 @@ export interface JsonApiResponse { included?: Included; } -export interface ApiData { +export interface ApiData { id: string; attributes: Attributes; embeds: Embeds; + type: string; + relationships: Relationships; } diff --git a/src/app/features/home/dashboard.service.ts b/src/app/features/home/dashboard.service.ts index ce715e0a0..0cef4b11b 100644 --- a/src/app/features/home/dashboard.service.ts +++ b/src/app/features/home/dashboard.service.ts @@ -31,7 +31,7 @@ export class DashboardService { getNoteworthy(): Observable { const projectId = 'pf5z9'; const params = { - embed: 'bibliographic_contributors', + 'embed[]': 'bibliographic_contributors', 'page[size]': 5, }; @@ -45,7 +45,7 @@ export class DashboardService { getMostPopular(): Observable { const projectId = 'kvw3y'; const params = { - embed: 'bibliographic_contributors', + 'embed[]': 'bibliographic_contributors', 'page[size]': 5, }; diff --git a/src/app/features/home/logged-out/home-logged-out.component.spec.ts b/src/app/features/home/logged-out/home-logged-out.component.spec.ts index 7f49c93e9..fb6846f89 100644 --- a/src/app/features/home/logged-out/home-logged-out.component.spec.ts +++ b/src/app/features/home/logged-out/home-logged-out.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LoggedOutComponent } from './home-logged-out.component'; +import { HomeLoggedOutComponent } from './home-logged-out.component'; describe('LoggedOutComponent', () => { - let component: LoggedOutComponent; - let fixture: ComponentFixture; + let component: HomeLoggedOutComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LoggedOutComponent], + imports: [HomeLoggedOutComponent], }).compileComponents(); - fixture = TestBed.createComponent(LoggedOutComponent); + fixture = TestBed.createComponent(HomeLoggedOutComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/home/models/raw-models/ProjectItem.entity.ts b/src/app/features/home/models/raw-models/ProjectItem.entity.ts index ceab31895..0d0bcfc7d 100644 --- a/src/app/features/home/models/raw-models/ProjectItem.entity.ts +++ b/src/app/features/home/models/raw-models/ProjectItem.entity.ts @@ -13,10 +13,12 @@ export type ProjectItem = ApiData< ApiData< BibliographicContributorUS, { - users: JsonApiResponse, null>; - } + users: JsonApiResponse, null>; + }, + null >[], null >; - } + }, + null >; diff --git a/src/app/features/search/data.ts b/src/app/features/search/data.ts deleted file mode 100644 index 90e6230f1..000000000 --- a/src/app/features/search/data.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Resource } from '@osf/features/search/models/resource.entity'; -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; - -export const resources: Resource[] = [ - { - id: 'https://osf.io/wrfgp', - resourceType: ResourceType.File, - dateCreated: new Date('2025-01-10'), - dateModified: new Date('2025-01-14'), - creators: [ - { - id: 'https://osf.io/a6t2x', - name: 'Lívia Rodrigues de Lima Pires', - }, - ], - fileName: 'dadosAnalisados.rds', - from: { - id: 'https://osf.io/e86jf', - name: 'Tese', - }, - }, - { - id: 'https://osf.io/4crzf', - resourceType: ResourceType.Project, - dateCreated: new Date('2025-01-15'), - dateModified: new Date('2025-01-18'), - creators: [ - { - id: 'https://osf.io/4fy2t', - name: 'Jane Simpson', - }, - { - id: 'https://osf.io/5jv47', - name: 'Wendy Francis', - }, - { - id: 'https://osf.io/6a5yb', - name: 'Daniel Wadsworth', - }, - { - id: 'https://osf.io/6g7nc', - name: 'Kristen Tulloch', - }, - { - id: 'https://osf.io/7a3tm', - name: 'Dr Tamara Sysak', - }, - { - id: 'https://osf.io/b8tvg', - name: 'PJ Humphreys', - }, - { - id: 'https://osf.io/n2hyv', - name: 'Alison Craswell', - }, - { - id: 'https://osf.io/qtnc8', - name: 'Apil Gurung', - }, - { - id: 'https://osf.io/qvuw6', - name: 'Karen Watson', - }, - { - id: 'https://osf.io/zwu3t', - name: 'Helen Szabo', - }, - ], - title: - 'Intergenerational community home sharing for older adults and university students: A scoping review protocol.', - description: - 'The objective of this scoping review is to review programs designed to facilitate matches between older adults residing within the community and university students. This protocol provides an overview of the topic, justification for and methods to be implemented in the conduct and reporting of the scoping review.', - }, - { - id: 'https://osf.io/pgmhr', - resourceType: ResourceType.File, - dateCreated: new Date('2025-01-17'), - dateModified: new Date('2025-01-17'), - creators: [{ id: 'https://osf.io/65nk7', name: 'Aline Miranda Ferreira' }], - fileName: 'Scoping review protocol.pdf', - license: 'MIT License', - from: { - id: 'https://osf.io/4wks9', - name: 'Instruments for Measuring Pain, Functionality, and Quality of Life Following Knee Fractures: A Scoping Review', - }, - }, - { - id: 'https://osf.io/f7h5v', - resourceType: ResourceType.File, - dateCreated: new Date('2025-01-29'), - dateModified: new Date('2025-01-29'), - creators: [{ id: 'https://osf.io/d7rtw', name: 'David P. Goldenberg' }], - fileName: 'PCB.zip', - from: { - id: 'https://osf.io/qs7r9', - name: 'Design Files', - }, - }, - { - id: 'https://osf.io/3ua54', - resourceType: ResourceType.Project, - dateCreated: new Date('2025-03-16'), - dateModified: new Date('2025-03-16'), - creators: [{ id: 'https://osf.io/uby48', name: 'Aadit Nair Sajeev' }], - title: - 'Unemployment of Locals in Kochi due to the Augmented Influx of Migrant Workers', - description: - 'This project page includes supplementals like interview transcript for a case study into the unemployment of locals due to increasing influx of migrant workers into the state.', - }, - { - id: 'https://osf.io/xy9qn', - resourceType: ResourceType.Project, - dateCreated: new Date('2025-03-16'), - dateModified: new Date('2025-03-16'), - creators: [ - { - id: 'https://osf.io/x87gf', - name: 'Doctor Jack Samuel Egerton MSci EngD', - }, - ], - title: 'Stone functions (tetration)', - }, - { - id: 'https://osf.io/cpm64', - resourceType: ResourceType.Registration, - dateCreated: new Date('2025-01-10'), - dateModified: new Date('2025-01-10'), - creators: [ - { - id: 'https://osf.io/4fy2t', - name: 'Simran Khanna', - }, - { - id: 'https://osf.io/5jv47', - name: 'Devika Shenoy', - }, - { - id: 'https://osf.io/6a5yb', - name: 'Steph Hendren', - }, - { - id: 'https://osf.io/6g7nc', - name: 'Christian Zirbes', - }, - { - id: 'https://osf.io/7a3tm', - name: 'Anthony Catanzano', - }, - { - id: 'https://osf.io/b8tvg', - name: 'Rachelle Shao', - }, - { - id: 'https://osf.io/n2hyv', - name: 'Sofiu Ogunbiyi', - }, - { - id: 'https://osf.io/qtnc8', - name: 'Evelyn Hunter', - }, - { - id: 'https://osf.io/qvuw6', - name: 'Katie Radulovacki', - }, - { - id: 'https://osf.io/zwu3t', - name: 'Muhamed Sanneh', - }, - ], - title: - 'A Scoping Review and Meta-analysis Exploring the Associations between Socioeconomic Identity and Management of Pediatric Extremity Fracture', - description: - 'The incidence and management of extremity fractures in pediatric patients can be influenced by social and financial deprivation. Previous studies have highlighted that the social determinants of health, such as socioeconomic status, race, and insurance type, are independently associated with the incidence of pediatric fractures . [1, 2] This underscores the need to understand how these factors specifically affect fracture management in pediatric populations.\n\nIn addition to incidence, socioeconomic status has been shown to impact the timing and type of treatment for fractures. Vazquez et al. demonstrated that adolescents from poor socioeconomic backgrounds were more likely to experience delayed surgical fixation of femoral fractures, which was associated with worse outcomes, including longer hospital stays and higher healthcare costs.[2] Similarly, Evans et al. reported that children from socially deprived areas had worse perceived function and pain outcomes after upper extremity fractures, even after receiving orthopedic treatment.[3] These findings suggest that social deprivation not only affects initial access to care but also influences recovery and long-term outcomes.\n\nThe proposed scoping review and meta-analysis will systematically evaluate the existing literature to map out the extent and nature of the impact of social and financial deprivation on extremity fracture management in pediatric patients. By including a wide range of sociodemographic variables and outcomes, this review aims to provide a comprehensive understanding of the disparities in fracture care. This will inform future research and policy-making to address these inequities and improve healthcare delivery for socially and economically disadvantaged pediatric populations.\n\n[1] Dy CJ, Lyman S, Do HT, Fabricant PD, Marx RG, Green DW. Socioeconomic factors are associated with frequency of repeat emergency department visits for pediatric closed fractures. J Pediatr Orthop. 2014;34(5):548-551. doi:10.1097/BPO.0000000000000143\n[2] Ramaesh R, Clement ND, Rennie L, Court-Brown C, Gaston MS. Social deprivation as a risk factor for fractures in childhood. Bone Joint J. 2015;97-B(2):240-245. doi:10.1302/0301-620X.97B2.34057\n[3] Vazquez S, Dominguez JF, Jacoby M, et al. Poor socioeconomic status is associated with delayed femoral fracture fixation in adolescent patients. Injury. 2023;54(12):111128. doi:10.1016/j.injury.2023.111128', - license: 'No license', - publisher: { - id: 'https://osf.io/registries/osf', - name: 'OSF Registries', - }, - registrationTemplate: 'Generalized Systematic Review Registration', - doi: 'https://doi.org/10.17605/OSF.IO/CPM64', - }, - { - id: 'https://osf.io/8tk45', - resourceType: ResourceType.Registration, - dateCreated: new Date('2025-03-06'), - dateModified: new Date('2025-03-06'), - creators: [ - { - id: 'https://osf.io/45kpt', - name: 'Laura A. King', - }, - { - id: 'https://osf.io/cuqrk', - name: 'Erica A. Holberg', - }, - ], - title: 'Consequentialist Criteria: Money x Effort (Between)', - description: - "This is the first of two studies aimed at testing whether the effect of amount of money raised for a good cause is present in within person comparisons, but null when utilizing a between person design. In previous studies, we have found that success in achieving an agent's morally good aim and high cost to agent for doing the right thing are salient to moral evaluators when the explicit contrast to failure or low cost is made (within person), but not particularly salient to moral evaluation when the explicit contrast is not drawn (between person). In this study, we are interested to determine the relative presence and strength of amount of money raised (a consequentialist criterion for moral evaluation) and effort put forth (a non-consequentialist moral criterion centered on the agent's will) when this is not explicitly contrasted because using a between-person design. In a pilot study, we found no effect of money upon moral goodness. There was an effect of money upon effort for the low condition ($50). We want to see how results are altered if we add an explicit factor for effort. Does amount of money have null effect upon perceived moral goodness? Does effort have a larger effect upon perceived moral goodness than amount of money raised? \n\nParticipants will read scenarios reflecting a 3 (money: extremely high vs. high vs. low) x 3 (effort: high vs. no mention vs. low) design. In all 9 scenarios, participants will rate the moral goodness of a person who raises money for and then runs in a 5K for a good cause, where the conditions vary as described above. They also will answer how likely it is that the target would undertake a similar volunteer commitment in the future.", - publisher: { - id: 'https://osf.io/registries/osf', - name: 'OSF Registries', - }, - registrationTemplate: 'OSF Preregistration', - license: 'No license', - doi: 'https://doi.org/10.17605/OSF.IO/8TK45', - }, - { - id: 'https://osf.io/wa6yf', - resourceType: ResourceType.File, - dateCreated: new Date('2025-01-14'), - dateModified: new Date('2025-01-14'), - creators: [ - { - id: 'https://osf.io/6nkxv', - name: 'Kari-Anne B. Næss', - }, - { - id: 'https://osf.io/b2g9q', - name: 'Frida Johanne Holmen ', - }, - { - id: 'https://osf.io/j6hd5', - name: 'Thormod Idsøe', - }, - ], - from: { - id: 'https://osf.io/tbzv6', - name: 'A Cross-sectional Study of Inference-making Comparing First- and Second Language Learners in Early Childhood Education and Care', - }, - fileName: 'Model_FH_prereg_140125.pdf', - license: 'No license', - }, - { - id: 'https://osf.io/4hg87', - resourceType: ResourceType.ProjectComponent, - dateCreated: new Date('2025-01-04'), - dateModified: new Date('2025-01-04'), - creators: [ - { - id: 'https://osf.io/2x5kc', - name: 'Bowen Wang-Kildegaard', - }, - ], - title: 'Dataset and Codes', - }, - { - id: 'https://osf.io/87vyr', - resourceType: ResourceType.Preprint, - dateCreated: new Date('2025-02-20'), - dateModified: new Date('2025-02-20'), - creators: [ - { - id: 'https://osf.io/2x5kc', - name: 'Eric L. Olson', - }, - ], - title: 'Evaluative Judgment Across Domains', - description: - 'Keberadaan suatu organisme pada suatu tempat dipengaruhi oleh faktor lingkungan dan makanan. Ketersediaan makanan dengan kualitas yang cocok dan kuantitas yang ukup bagi suatu organisme akan meningkatkan populasi cepat. Sebaliknya jika keadaan tersebut tidak mendukung maka akan dipastikan bahwa organisme tersebut akan menurun. Sedangkan faktor abiotik meliputi suhu, kelembaban, cahaya, curah hujan, dan angin. Suhu mempengaruhi aktivitas serangga serta perkembangannya. Serangga juga tertarik pada gelombang cahaya tertentu. Serangga ada yang menerima intensitas cahaya yang tinggi dan aktif pada siang hari (diurnal) dan serangga ada yang aktif menerima intensitas cahaya rendah pada malam hari (nokturnal). Metode yang digunakan yaitu metode Light trap. Hail yang didapatkan bahwa komponen lingkungan (biotik dan abiotik) akan mempengaruhi kelimpahan dan keanekaragaman spesies pada suatu tempat sehingga tingginya kelimpahan individu tiap jenis dapat dipakai untuk menilai kualitas suatu habitat. Sehingga tidak semua serangga yang aktif pada siang hari tidak dapat aktif di malam hari karena efek adanya sinar tergantung sepenuhnya pada kondisi temperature dan kelembaban disekitar.', - provider: { - id: 'https://osf.io/preprints/osf', - name: 'Open Science Framework', - }, - conflictOfInterestResponse: 'Author asserted no Conflict of Interest', - license: 'No License', - doi: 'https://doi.org/10.31227/osf.io/fcs5r', - }, - { - id: 'https://osf.io/87vyr', - resourceType: ResourceType.Preprint, - dateCreated: new Date('2025-02-20'), - dateModified: new Date('2025-02-20'), - creators: [ - { - id: 'https://osf.io/2x5kc', - name: 'Fitha Kaamiliyaa Hamka', - }, - ], - title: - 'Identifikasi Serangga Nokturnal di Bukit Samata Kabupaten Gowa, Sulawesi Selatan', - description: - 'Keberadaan suatu organisme pada suatu tempat dipengaruhi oleh faktor lingkungan dan makanan. Ketersediaan makanan dengan kualitas yang cocok dan kuantitas yang ukup bagi suatu organisme akan meningkatkan populasi cepat. Sebaliknya jika keadaan tersebut tidak mendukung maka akan dipastikan bahwa organisme tersebut akan menurun. Sedangkan faktor abiotik meliputi suhu, kelembaban, cahaya, curah hujan, dan angin. Suhu mempengaruhi aktivitas serangga serta perkembangannya. Serangga juga tertarik pada gelombang cahaya tertentu. Serangga ada yang menerima intensitas cahaya yang tinggi dan aktif pada siang hari (diurnal) dan serangga ada yang aktif menerima intensitas cahaya rendah pada malam hari (nokturnal). Metode yang digunakan yaitu metode Light trap. Hail yang didapatkan bahwa komponen lingkungan (biotik dan abiotik) akan mempengaruhi kelimpahan dan keanekaragaman spesies pada suatu tempat sehingga tingginya kelimpahan individu tiap jenis dapat dipakai untuk menilai kualitas suatu habitat. Sehingga tidak semua serangga yang aktif pada siang hari tidak dapat aktif di malam hari karena efek adanya sinar tergantung sepenuhnya pada kondisi temperature dan kelembaban disekitar.', - provider: { - id: 'https://osf.io/preprints/osf', - name: 'Open Science Framework', - }, - conflictOfInterestResponse: 'Author asserted no Conflict of Interest', - license: 'No License', - doi: 'https://doi.org/10.31234/osf.io/7mgkd', - }, - { - id: 'https://osf.io/cegxv', - resourceType: ResourceType.User, - title: 'Amelia Jamison', - publicProjects: 2, - publicRegistrations: 0, - publicPreprints: 0, - }, - { - id: 'https://osf.io/cegxv', - resourceType: ResourceType.User, - title: 'Cristal Alvarez', - publicProjects: 2, - publicRegistrations: 0, - publicPreprints: 0, - orcid: 'https://orcid.org/0000-0003-2697-7146', - }, - { - id: 'https://osf.io/cegxv', - resourceType: ResourceType.User, - title: 'Rohini Ganjoo', - publicProjects: 16, - publicRegistrations: 6, - publicPreprints: 3, - orcid: 'https://orcid.org/0000-0003-2697-7146', - employment: 'University of Turku', - education: 'University of Turku', - }, -]; diff --git a/src/app/features/search/mappers/search.mapper.ts b/src/app/features/search/mappers/search.mapper.ts new file mode 100644 index 000000000..7af9bb17b --- /dev/null +++ b/src/app/features/search/mappers/search.mapper.ts @@ -0,0 +1,50 @@ +import { ResourceItem } from '@osf/features/search/models/raw-models/resource-response.model'; +import { Resource } from '@osf/features/search/models/resource.entity'; +import { ResourceType } from '@osf/features/search/models/resource-type.enum'; +import { LinkItem } from '@osf/features/search/models/link-item.entity'; + +export function MapResources(rawItem: ResourceItem): Resource { + return { + id: rawItem['@id'], + resourceType: + ResourceType[ + rawItem?.resourceType[0]['@id'] as keyof typeof ResourceType + ], + dateCreated: rawItem?.dateCreated?.[0]?.['@value'] + ? new Date(rawItem?.dateCreated?.[0]?.['@value']) + : undefined, + dateModified: rawItem?.dateModified?.[0]?.['@value'] + ? new Date(rawItem?.dateModified?.[0]?.['@value']) + : undefined, + creators: (rawItem?.creator ?? []).map( + (creator) => + ({ + id: creator?.['@id'], + name: creator?.name?.[0]?.['@value'], + }) as LinkItem, + ), + fileName: rawItem?.fileName?.[0]?.['@value'], + title: rawItem?.title?.[0]?.['@value'] ?? rawItem?.name?.[0]?.['@value'], + description: rawItem?.description?.[0]?.['@value'], + from: { + id: rawItem?.isPartOf?.[0]?.['@id'], + name: rawItem?.isPartOf?.[0]?.title?.[0]?.['@value'], + }, + license: { + id: rawItem?.rights?.[0]?.['@id'], + name: rawItem?.rights?.[0]?.name?.[0]?.['@value'], + }, + provider: { + id: rawItem?.publisher?.[0]?.['@id'], + name: rawItem?.publisher?.[0]?.name?.[0]?.['@value'], + }, + registrationTemplate: rawItem?.conformsTo?.[0]?.title?.[0]?.['@value'], + doi: rawItem?.identifier?.[0]?.['@value'], + conflictOfInterestResponse: rawItem?.statedConflictOfInterest?.[0]?.['@id'], + hasDataResource: !!rawItem?.hasDataResource, + hasAnalyticCodeResource: !!rawItem?.hasAnalyticCodeResource, + hasMaterialsResource: !!rawItem?.hasMaterialsResource, + hasPapersResource: !!rawItem?.hasPapersResource, + hasSupplementalResource: !!rawItem?.hasSupplementalResource, + }; +} diff --git a/src/app/features/search/models/raw-models/index-card-search.model.ts b/src/app/features/search/models/raw-models/index-card-search.model.ts new file mode 100644 index 000000000..fcc18ad48 --- /dev/null +++ b/src/app/features/search/models/raw-models/index-card-search.model.ts @@ -0,0 +1,27 @@ +import { + ApiData, + JsonApiResponse, +} from '@core/services/json-api/json-api.entity'; +import { ResourceItem } from '@osf/features/search/models/raw-models/resource-response.model'; + +export type IndexCardSearch = JsonApiResponse< + { + attributes: { totalResultCount: number }; + relationships: { + searchResultPage: { + links: { + first: { + href: string; + }; + next: { + href: string; + }; + prev: { + href: string; + }; + }; + }; + }; + }, + ApiData<{ resourceMetadata: ResourceItem }, null, null>[] +>; diff --git a/src/app/features/search/models/raw-models/resource-response.model.ts b/src/app/features/search/models/raw-models/resource-response.model.ts new file mode 100644 index 000000000..c7ef5db34 --- /dev/null +++ b/src/app/features/search/models/raw-models/resource-response.model.ts @@ -0,0 +1,78 @@ +import { MetadataField } from '@shared/entities/metadata-field.inteface'; + +export interface ResourceItem { + '@id': string; + accessService: MetadataField[]; + affiliation: MetadataField[]; + creator: ResourceCreator[]; + conformsTo: ConformsTo[]; + dateCopyrighted: { '@value': string }[]; + dateCreated: { '@value': string }[]; + dateModified: { '@value': string }[]; + description: { '@value': string }[]; + hasPreregisteredAnalysisPlan: { '@id': string }[]; + hasPreregisteredStudyDesign: { '@id': string }[]; + hostingInstitution: HostingInstitution[]; + identifier: { '@value': string }[]; + keyword: { '@value': string }[]; + publisher: MetadataField[]; + resourceNature: ResourceNature[]; + qualifiedAttribution: QualifiedAttribution[]; + resourceType: { '@id': string }[]; + title: { '@value': string }[]; + name: { '@value': string }[]; + fileName: { '@value': string }[]; + isPartOf: isPartOf[]; + isPartOfCollection: IsPartOfCollection[]; + rights: MetadataField[]; + statedConflictOfInterest: { '@id': string }[]; + hasDataResource: MetadataField[]; + hasAnalyticCodeResource: MetadataField[]; + hasMaterialsResource: MetadataField[]; + hasPapersResource: MetadataField[]; + hasSupplementalResource: MetadataField[]; +} + +export interface ResourceCreator extends MetadataField { + affiliation: MetadataField[]; + sameAs: { '@id': string }[]; +} + +export interface HostingInstitution extends MetadataField { + sameAs: MetadataField[]; +} + +export interface QualifiedAttribution { + agent: { '@id': string }[]; + hadRole: { '@id': string }[]; +} + +export interface isPartOf extends MetadataField { + creator: ResourceCreator[]; + dateCopyright: { '@value': string }[]; + dateCreated: { '@value': string }[]; + publisher: MetadataField[]; + rights: MetadataField[]; + rightHolder: { '@value': string }[]; + sameAs: { '@id': string }[]; + title: { '@value': string }[]; +} + +export interface IsPartOfCollection { + '@id': string; + resourceNature: { '@id': string }[]; + title: { '@value': string }[]; +} + +export interface ResourceNature { + '@id': string; + displayLabel: { + '@language': string; + '@value': string; + }[]; +} + +export interface ConformsTo { + '@id': string; + title: { '@value': string }[]; +} diff --git a/src/app/features/search/models/resource-type.enum.ts b/src/app/features/search/models/resource-type.enum.ts index 4750fc6bd..61ac6ded6 100644 --- a/src/app/features/search/models/resource-type.enum.ts +++ b/src/app/features/search/models/resource-type.enum.ts @@ -5,5 +5,5 @@ export enum ResourceType { Registration, Preprint, ProjectComponent, - User, + Agent, } diff --git a/src/app/features/search/models/resource.entity.ts b/src/app/features/search/models/resource.entity.ts index 2898c17d6..b193d3c1e 100644 --- a/src/app/features/search/models/resource.entity.ts +++ b/src/app/features/search/models/resource.entity.ts @@ -11,11 +11,10 @@ export interface Resource { title?: string; description?: string; from?: LinkItem; - license?: string; - publisher?: LinkItem; + license?: LinkItem; + provider?: LinkItem; registrationTemplate?: string; doi?: string; - provider?: LinkItem; conflictOfInterestResponse?: string; publicProjects?: number; publicRegistrations?: number; @@ -23,4 +22,9 @@ export interface Resource { orcid?: string; employment?: string; education?: string; + hasDataResource: boolean; + hasAnalyticCodeResource: boolean; + hasMaterialsResource: boolean; + hasPapersResource: boolean; + hasSupplementalResource: boolean; } diff --git a/src/app/features/search/models/resources-data.entity.ts b/src/app/features/search/models/resources-data.entity.ts new file mode 100644 index 000000000..643db2a59 --- /dev/null +++ b/src/app/features/search/models/resources-data.entity.ts @@ -0,0 +1,9 @@ +import { Resource } from '@osf/features/search/models/resource.entity'; + +export interface ResourcesData { + resources: Resource[]; + count: number; + first: string; + next: string; + previous: string; +} diff --git a/src/app/features/search/search.component.html b/src/app/features/search/search.component.html index 31d83ce9e..21987bc9a 100644 --- a/src/app/features/search/search.component.html +++ b/src/app/features/search/search.component.html @@ -2,8 +2,13 @@ better-research -
+
- - - - - - - - - - - - - - + > - - - - - + > - - - - - - + +
+ + + @if (currentStep === 1) { +
+

Improved OSF Search

+

+ Enter any term in the search box and filter by specific object types. + More information is available on our help guides. +

+
+

1 of 3

+
+ + Next +
+
+
+ } + + @if (currentStep === 2) { +
+

Refine Your Search

+

+ Narrow the source, discipline, and more. For example, find content + supported by a specific funder or view only datasets. +

+
+

2 of 3

+
+ + Next +
+
+
+ } + + @if (currentStep === 3) { +
+

Add Metadata

+

+ Remember to add metadata and resources to your own work on OSF to make + it more discoverable! Learn more in our help guides. +

+
+

3 of 3

+
+ Done +
+
+
+ } +
diff --git a/src/app/features/search/search.component.scss b/src/app/features/search/search.component.scss index 7d99fd2e6..9d28d34d9 100644 --- a/src/app/features/search/search.component.scss +++ b/src/app/features/search/search.component.scss @@ -12,4 +12,40 @@ z-index: 1; } } + + .resources { + position: relative; + background: white; + padding: 2rem; + } + + .stepper { + position: absolute; + padding: 1.7rem; + width: 32rem; + display: flex; + flex-direction: column; + row-gap: 1.7rem; + background: white; + border: 1px solid var.$grey-2; + border-radius: 12px; + h3 { + font-size: 1.3rem; + } + } + + .first-stepper { + top: 2rem; + left: 1.7rem; + } + + .second-stepper { + top: calc(2rem + 42px); + left: calc(1.5rem + 30%); + } + + .third-stepper { + top: calc(5rem + 42px); + left: calc(0.4rem + 30%); + } } diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index 823ab02bd..a82436ed4 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -1,16 +1,17 @@ import { ChangeDetectionStrategy, Component, - computed, + effect, inject, signal, + untracked, } from '@angular/core'; import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; import { DropdownModule } from 'primeng/dropdown'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { NgOptimizedImage } from '@angular/common'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { AutoCompleteModule } from 'primeng/autocomplete'; import { AccordionModule } from 'primeng/accordion'; @@ -18,8 +19,17 @@ import { TableModule } from 'primeng/table'; import { DataViewModule } from 'primeng/dataview'; import { ResourcesComponent } from '@shared/components/resources/resources.component'; import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; -import { Resource } from '@osf/features/search/models/resource.entity'; -import { resources } from '@osf/features/search/data'; +import { Store } from '@ngxs/store'; +import { + GetResources, + SearchSelectors, + SetResourceTab, + SetSearchText, +} from '@osf/features/search/store'; +import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; +import { debounceTime } from 'rxjs'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { Button } from 'primeng/button'; @Component({ selector: 'osf-search', @@ -39,50 +49,107 @@ import { resources } from '@osf/features/search/data'; TableModule, DataViewModule, ResourcesComponent, + Button, ], templateUrl: './search.component.html', styleUrl: './search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchComponent { + readonly #store = inject(Store); + protected searchValue = signal(''); - protected selectedTab = 0; protected readonly isMobile = toSignal(inject(IS_XSMALL)); - protected readonly resources = signal(resources); - protected readonly searchedResources = computed(() => { - const search = this.searchValue().toLowerCase(); - return this.resources().filter( - (resource: Resource) => - resource.title?.toLowerCase().includes(search) || - resource.fileName?.toLowerCase().includes(search) || - resource.description?.toLowerCase().includes(search) || - resource.creators - ?.map((p) => p.name.toLowerCase()) - .some((name) => name.includes(search)) || - resource.dateCreated - ?.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }) - .toLowerCase() - .includes(search) || - resource.dateModified - ?.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }) - .toLowerCase() - .includes(search) || - resource.from?.name.toLowerCase().includes(search), - ); - }); + protected readonly creatorsFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getCreator, + ); + protected readonly dateCreatedFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getDateCreated, + ); + protected readonly funderFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getFunder, + ); + protected readonly subjectFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getSubject, + ); + protected readonly licenseFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getLicense, + ); + protected readonly resourceTypeFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getResourceType, + ); + protected readonly institutionFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getInstitution, + ); + protected readonly providerFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getProvider, + ); + protected readonly partOfCollectionFilter = this.#store.selectSignal( + ResourceFiltersSelectors.getPartOfCollection, + ); + protected searchStoreValue = this.#store.selectSignal( + SearchSelectors.getSearchText, + ); + protected resourcesTabStoreValue = this.#store.selectSignal( + SearchSelectors.getResourceTab, + ); + protected sortByStoreValue = this.#store.selectSignal( + SearchSelectors.getSortBy, + ); - onTabChange(index: number): void { - this.selectedTab = index; + protected selectedTab: ResourceTab = ResourceTab.All; + protected readonly ResourceTab = ResourceTab; + protected currentStep = 0; + + constructor() { + effect(() => { + this.creatorsFilter(); + this.dateCreatedFilter(); + this.funderFilter(); + this.subjectFilter(); + this.licenseFilter(); + this.resourceTypeFilter(); + this.institutionFilter(); + this.providerFilter(); + this.partOfCollectionFilter(); + this.searchStoreValue(); + this.resourcesTabStoreValue(); + this.sortByStoreValue(); + this.#store.dispatch(GetResources); + }); + + // put search value in store and update resources, filters + toObservable(this.searchValue) + .pipe(debounceTime(500)) + .subscribe((searchText) => { + this.#store.dispatch(new SetSearchText(searchText)); + this.#store.dispatch(GetAllOptions); + }); + + // sync search with query parameters if search is empty and parameters are not + effect(() => { + const storeValue = this.searchStoreValue(); + const currentInput = untracked(() => this.searchValue()); + + if (storeValue && currentInput !== storeValue) { + this.searchValue.set(storeValue); + } + }); + + // sync resource tabs with query parameters + effect(() => { + if ( + !this.selectedTab && + this.selectedTab !== this.resourcesTabStoreValue() + ) { + this.selectedTab = this.resourcesTabStoreValue(); + } + }); } - protected readonly ResourceTab = ResourceTab; + onTabChange(index: ResourceTab): void { + this.#store.dispatch(new SetResourceTab(index)); + this.selectedTab = index; + } } diff --git a/src/app/features/search/search.service.ts b/src/app/features/search/search.service.ts new file mode 100644 index 000000000..c7e8bb462 --- /dev/null +++ b/src/app/features/search/search.service.ts @@ -0,0 +1,83 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { map, Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { MapResources } from '@osf/features/search/mappers/search.mapper'; +import { IndexCardSearch } from '@osf/features/search/models/raw-models/index-card-search.model'; +import { ResourcesData } from '@osf/features/search/models/resources-data.entity'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchService { + #jsonApiService = inject(JsonApiService); + + getResources( + filters: Record, + searchText: string, + sortBy: string, + resourceType: string, + ): Observable { + const params: Record = { + 'cardSearchFilter[resourceType]': resourceType ?? '', + 'cardSearchFilter[accessService]': 'https://staging4.osf.io/', + 'cardSearchText[*,creator.name,isContainedBy.creator.name]': + searchText ?? '', + 'page[size]': '10', + sort: sortBy, + ...filters, + }; + + return this.#jsonApiService + .get( + `${environment.shareDomainUrl}/index-card-search`, + params, + ) + .pipe( + map((response) => { + if (response?.included) { + return { + resources: response?.included + .filter((item) => item.type === 'index-card') + .map((item) => MapResources(item.attributes.resourceMetadata)), + count: response.data.attributes.totalResultCount, + first: + response.data?.relationships?.searchResultPage?.links?.first + ?.href, + next: response.data?.relationships?.searchResultPage?.links?.next + ?.href, + previous: + response.data?.relationships?.searchResultPage?.links?.prev + ?.href, + }; + } + + return {} as ResourcesData; + }), + ); + } + + getResourcesByLink(link: string): Observable { + return this.#jsonApiService.get(link).pipe( + map((response) => { + if (response?.included) { + return { + resources: response.included + .filter((item) => item.type === 'index-card') + .map((item) => MapResources(item.attributes.resourceMetadata)), + count: response.data.attributes.totalResultCount, + first: + response.data?.relationships?.searchResultPage?.links?.first + ?.href, + next: response.data?.relationships?.searchResultPage?.links?.next + ?.href, + previous: + response.data?.relationships?.searchResultPage?.links?.prev?.href, + }; + } + + return {} as ResourcesData; + }), + ); + } +} diff --git a/src/app/features/search/store/index.ts b/src/app/features/search/store/index.ts new file mode 100644 index 000000000..c491f1685 --- /dev/null +++ b/src/app/features/search/store/index.ts @@ -0,0 +1,4 @@ +export * from './search.actions'; +export * from './search.model'; +export * from './search.selectors'; +export * from './search.state'; diff --git a/src/app/features/search/store/search.actions.ts b/src/app/features/search/store/search.actions.ts new file mode 100644 index 000000000..bf55a3603 --- /dev/null +++ b/src/app/features/search/store/search.actions.ts @@ -0,0 +1,33 @@ +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; + +export class GetResources { + static readonly type = '[Search] Get Resources'; +} + +export class GetResourcesByLink { + static readonly type = '[Search] Get Resources By Link'; + + constructor(public link: string) {} +} + +export class GetResourcesCount { + static readonly type = '[Search] Get Resources Count'; +} + +export class SetSearchText { + static readonly type = '[Search] Set Search Text'; + + constructor(public searchText: string) {} +} + +export class SetSortBy { + static readonly type = '[Search] Set SortBy'; + + constructor(public sortBy: string) {} +} + +export class SetResourceTab { + static readonly type = '[Search] Set Resource Tab'; + + constructor(public resourceTab: ResourceTab) {} +} diff --git a/src/app/features/search/store/search.model.ts b/src/app/features/search/store/search.model.ts new file mode 100644 index 000000000..f8c128bde --- /dev/null +++ b/src/app/features/search/store/search.model.ts @@ -0,0 +1,13 @@ +import { Resource } from '@osf/features/search/models/resource.entity'; +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; + +export interface SearchStateModel { + resources: Resource[]; + resourcesCount: number; + searchText: string; + sortBy: string; + resourceTab: ResourceTab; + first: string; + next: string; + previous: string; +} diff --git a/src/app/features/search/store/search.selectors.ts b/src/app/features/search/store/search.selectors.ts new file mode 100644 index 000000000..929ade1b8 --- /dev/null +++ b/src/app/features/search/store/search.selectors.ts @@ -0,0 +1,47 @@ +import { SearchState } from '@osf/features/search/store/search.state'; +import { Selector } from '@ngxs/store'; +import { SearchStateModel } from '@osf/features/search/store/search.model'; +import { Resource } from '@osf/features/search/models/resource.entity'; +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; + +export class SearchSelectors { + @Selector([SearchState]) + static getResources(state: SearchStateModel): Resource[] { + return state.resources; + } + + @Selector([SearchState]) + static getResourcesCount(state: SearchStateModel): number { + return state.resourcesCount; + } + + @Selector([SearchState]) + static getSearchText(state: SearchStateModel): string { + return state.searchText; + } + + @Selector([SearchState]) + static getSortBy(state: SearchStateModel): string { + return state.sortBy; + } + + @Selector([SearchState]) + static getResourceTab(state: SearchStateModel): ResourceTab { + return state.resourceTab; + } + + @Selector([SearchState]) + static getFirst(state: SearchStateModel): string { + return state.first; + } + + @Selector([SearchState]) + static getNext(state: SearchStateModel): string { + return state.next; + } + + @Selector([SearchState]) + static getPrevious(state: SearchStateModel): string { + return state.previous; + } +} diff --git a/src/app/features/search/store/search.state.ts b/src/app/features/search/store/search.state.ts new file mode 100644 index 000000000..db69e3546 --- /dev/null +++ b/src/app/features/search/store/search.state.ts @@ -0,0 +1,93 @@ +import { inject, Injectable } from '@angular/core'; +import { SearchService } from '@osf/features/search/search.service'; +import { SearchStateModel } from '@osf/features/search/store/search.model'; +import { Action, State, StateContext, Store } from '@ngxs/store'; +import { + GetResources, + GetResourcesByLink, + SetResourceTab, + SetSearchText, + SetSortBy, +} from '@osf/features/search/store/search.actions'; +import { tap } from 'rxjs'; +import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; +import { addFiltersParams } from '@shared/components/resources/resource-filters/utils/add-filters-params.helper'; +import { SearchSelectors } from '@osf/features/search/store/search.selectors'; +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; +import { getResourceTypes } from '@osf/features/search/utils/helpers/get-resource-types.helper'; + +@Injectable() +@State({ + name: 'search', + defaults: { + resources: [], + resourcesCount: 0, + searchText: '', + sortBy: '-relevance', + resourceTab: ResourceTab.All, + first: '', + next: '', + previous: '', + }, +}) +export class SearchState { + searchService = inject(SearchService); + store = inject(Store); + + @Action(GetResources) + getResources(ctx: StateContext) { + const filters = this.store.selectSnapshot( + ResourceFiltersSelectors.getAllFilters, + ); + const filtersParams = addFiltersParams(filters); + const searchText = this.store.selectSnapshot(SearchSelectors.getSearchText); + const sortBy = this.store.selectSnapshot(SearchSelectors.getSortBy); + const resourceTab = this.store.selectSnapshot( + SearchSelectors.getResourceTab, + ); + const resourceTypes = getResourceTypes(resourceTab); + + return this.searchService + .getResources(filtersParams, searchText, sortBy, resourceTypes) + .pipe( + tap((response) => { + ctx.patchState({ resources: response.resources }); + ctx.patchState({ resourcesCount: response.count }); + ctx.patchState({ first: response.first }); + ctx.patchState({ next: response.next }); + ctx.patchState({ previous: response.previous }); + }), + ); + } + + @Action(GetResourcesByLink) + getResourcesByLink( + ctx: StateContext, + action: GetResourcesByLink, + ) { + return this.searchService.getResourcesByLink(action.link).pipe( + tap((response) => { + ctx.patchState({ resources: response.resources }); + ctx.patchState({ resourcesCount: response.count }); + ctx.patchState({ first: response.first }); + ctx.patchState({ next: response.next }); + ctx.patchState({ previous: response.previous }); + }), + ); + } + + @Action(SetSearchText) + setSearchText(ctx: StateContext, action: SetSearchText) { + ctx.patchState({ searchText: action.searchText }); + } + + @Action(SetSortBy) + setSortBy(ctx: StateContext, action: SetSortBy) { + ctx.patchState({ sortBy: action.sortBy }); + } + + @Action(SetResourceTab) + setResourceTab(ctx: StateContext, action: SetResourceTab) { + ctx.patchState({ resourceTab: action.resourceTab }); + } +} diff --git a/src/app/features/search/utils/helpers/get-resource-types.helper.ts b/src/app/features/search/utils/helpers/get-resource-types.helper.ts new file mode 100644 index 000000000..32e048eb0 --- /dev/null +++ b/src/app/features/search/utils/helpers/get-resource-types.helper.ts @@ -0,0 +1,18 @@ +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; + +export function getResourceTypes(resourceTab: ResourceTab): string { + switch (resourceTab) { + case ResourceTab.Projects: + return 'Project,ProjectComponent'; + case ResourceTab.Registrations: + return 'Registration,RegistrationComponent'; + case ResourceTab.Preprints: + return 'Preprint'; + case ResourceTab.Files: + return 'File'; + case ResourceTab.Users: + return 'Agent'; + default: + return 'Registration,RegistrationComponent,Project,ProjectComponent,Preprint,Agent,File'; + } +} diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.html b/src/app/shared/components/resources/filter-chips/filter-chips.component.html new file mode 100644 index 000000000..9e794fe9e --- /dev/null +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.html @@ -0,0 +1,141 @@ +@if (filters().creator.value) { + @let creator = filters().creator.filterName + ": " + filters().creator.label; + + + + + +} + +@if (filters().dateCreated.value) { + @let dateCreated = + filters().dateCreated.filterName + ": " + filters().dateCreated.label; + + + + + +} + +@if (filters().funder.value) { + @let funder = filters().funder.filterName + ": " + filters().funder.label; + + + + + +} + +@if (filters().subject.value) { + @let subject = filters().subject.filterName + ": " + filters().subject.label; + + + + + +} + +@if (filters().license.value) { + @let license = filters().license.filterName + ": " + filters().license.label; + + + + + +} + +@if (filters().resourceType.value) { + @let resourceType = + filters().resourceType.filterName + ": " + filters().resourceType.label; + + + + + +} + +@if (filters().institution.value) { + @let institution = + filters().institution.filterName + ": " + filters().institution.label; + + + + + +} + +@if (filters().provider.value) { + @let provider = + filters().provider.filterName + ": " + filters().provider.label; + + + + + +} + +@if (filters().partOfCollection.value) { + @let partOfCollection = + filters().partOfCollection.filterName + + ": " + + filters().partOfCollection.label; + + + + + +} diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.scss b/src/app/shared/components/resources/filter-chips/filter-chips.component.scss new file mode 100644 index 000000000..442a7a495 --- /dev/null +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + row-gap: 0.4rem; +} diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.spec.ts b/src/app/shared/components/resources/filter-chips/filter-chips.component.spec.ts new file mode 100644 index 000000000..2c6f477c2 --- /dev/null +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilterChipsComponent } from './filter-chips.component'; + +describe('FilterChipsComponent', () => { + let component: FilterChipsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterChipsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FilterChipsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.ts b/src/app/shared/components/resources/filter-chips/filter-chips.component.ts new file mode 100644 index 000000000..953c9ac3b --- /dev/null +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetCreator, + SetDateCreated, + SetFunder, + SetInstitution, + SetLicense, + SetPartOfCollection, + SetProvider, + SetResourceType, + SetSubject, +} from '@shared/components/resources/resource-filters/store'; +import { Chip } from 'primeng/chip'; +import { PrimeTemplate } from 'primeng/api'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { FilterType } from '@shared/components/resources/resource-filters/models/filter-type.enum'; + +@Component({ + selector: 'osf-filter-chips', + imports: [Chip, PrimeTemplate], + templateUrl: './filter-chips.component.html', + styleUrl: './filter-chips.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilterChipsComponent { + readonly #store = inject(Store); + + protected filters = this.#store.selectSignal( + ResourceFiltersSelectors.getAllFilters, + ); + + clearFilter(filter: FilterType) { + switch (filter) { + case FilterType.Creator: + this.#store.dispatch(new SetCreator('', '')); + break; + case FilterType.DateCreated: + this.#store.dispatch(new SetDateCreated('')); + break; + case FilterType.Funder: + this.#store.dispatch(new SetFunder('', '')); + break; + case FilterType.Subject: + this.#store.dispatch(new SetSubject('', '')); + break; + case FilterType.License: + this.#store.dispatch(new SetLicense('', '')); + break; + case FilterType.ResourceType: + this.#store.dispatch(new SetResourceType('', '')); + break; + case FilterType.Institution: + this.#store.dispatch(new SetInstitution('', '')); + break; + case FilterType.Provider: + this.#store.dispatch(new SetProvider('', '')); + break; + case FilterType.PartOfCollection: + this.#store.dispatch(new SetPartOfCollection('', '')); + break; + } + this.#store.dispatch(GetAllOptions); + } + + protected readonly FilterType = FilterType; +} diff --git a/src/app/shared/components/resources/resource-card/mappers/user-counts.mapper.ts b/src/app/shared/components/resources/resource-card/mappers/user-counts.mapper.ts new file mode 100644 index 000000000..714fc8475 --- /dev/null +++ b/src/app/shared/components/resources/resource-card/mappers/user-counts.mapper.ts @@ -0,0 +1,16 @@ +import { UserCountsResponse } from '@shared/components/resources/resource-card/models/user-counts-response.entity'; +import { UserRelatedDataCounts } from '@shared/components/resources/resource-card/models/user-related-data-counts.entity'; + +export function MapUserCounts( + response: UserCountsResponse, +): UserRelatedDataCounts { + return { + projects: response.data?.relationships?.nodes?.links?.related?.meta?.count, + registrations: + response.data?.relationships?.registrations?.links?.related?.meta?.count, + preprints: + response.data?.relationships?.preprints?.links?.related?.meta?.count, + employment: response.data?.attributes?.employment?.[0]?.institution, + education: response.data?.attributes?.education?.[0]?.institution, + }; +} diff --git a/src/app/shared/components/resources/resource-card/models/user-counts-response.entity.ts b/src/app/shared/components/resources/resource-card/models/user-counts-response.entity.ts new file mode 100644 index 000000000..c8eff45b4 --- /dev/null +++ b/src/app/shared/components/resources/resource-card/models/user-counts-response.entity.ts @@ -0,0 +1,44 @@ +import { + ApiData, + JsonApiResponse, +} from '@core/services/json-api/json-api.entity'; + +export type UserCountsResponse = JsonApiResponse< + ApiData< + { + employment: { institution: string }[]; + education: { institution: string }[]; + }, + null, + { + registrations: { + links: { + related: { + meta: { + count: number; + }; + }; + }; + }; + preprints: { + links: { + related: { + meta: { + count: number; + }; + }; + }; + }; + nodes: { + links: { + related: { + meta: { + count: number; + }; + }; + }; + }; + } + >, + null +>; diff --git a/src/app/shared/components/resources/resource-card/models/user-related-data-counts.entity.ts b/src/app/shared/components/resources/resource-card/models/user-related-data-counts.entity.ts new file mode 100644 index 000000000..8a77d9954 --- /dev/null +++ b/src/app/shared/components/resources/resource-card/models/user-related-data-counts.entity.ts @@ -0,0 +1,7 @@ +export interface UserRelatedDataCounts { + projects: number; + registrations: number; + preprints: number; + employment?: string; + education?: string; +} diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.html b/src/app/shared/components/resources/resource-card/resource-card.component.html index e9b2b1f91..d7d9feca1 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.html +++ b/src/app/shared/components/resources/resource-card/resource-card.component.html @@ -1,18 +1,26 @@
- +
-

{{ ResourceType[item().resourceType] }}

+ @if ( + item()?.resourceType && item()?.resourceType === ResourceType.Agent + ) { +

User

+ } @else if (item()?.resourceType) { +

{{ ResourceType[item()?.resourceType!] }}

+ }
- @if (item().resourceType === ResourceType.File) { - {{ item().fileName }} - } @else { - {{ item().title }} + @if ( + item()?.resourceType === ResourceType.File && item()?.fileName + ) { + {{ item()?.fileName }} + } @else if (item()?.title && item()?.title) { + {{ item()?.title }} } - @if (item().orcid) { - + @if (item()?.orcid) { + - @if (item().creators?.length) { + @if (item()?.creators?.length) {
@for ( - creator of item().creators!.slice(0, 4); + creator of item()?.creators!.slice(0, 4); track creator.id; let i = $index ) { {{ creator.name }} - @if (i < item().creators!.length - 1 && i < 3) { + @if (i < (item()?.creators)!.length - 1 && i < 3) { , } } - @if (item().creators!.length > 4) { + @if ((item()?.creators)!.length > 4) {

-  and {{ item().creators!.length - 4 }} more +  and {{ (item()?.creators)!.length - 4 }} more

}
} - @if (item().from) { + @if (item()?.from?.id && item()?.from?.name) { } - @if (item().dateCreated && item().dateModified) { + @if (item()?.dateCreated && item()?.dateModified) {

- Date created: {{ item().dateCreated | date: "MMMM d, y" }} | Date - modified: {{ item().dateModified | date: "MMMM d, y" }} + Date created: {{ item()?.dateCreated | date: "MMMM d, y" }} | Date + modified: {{ item()?.dateModified | date: "MMMM d, y" }}

} - @if (item().resourceType === ResourceType.Registration) { + @if (item()?.resourceType === ResourceType.Registration) { @@ -87,63 +115,81 @@
- @if (item().description) { -

Description: {{ item().description }}

+ @if (item()?.description) { +

Description: {{ item()?.description }}

} - @if (item().publisher) { + @if (item()?.provider?.id) { -

Registration provider:

- {{ item().publisher?.name }} +

Registration provider: 

+ {{ item()?.provider?.name }}
} - @if (item().registrationTemplate) { + @if (item()?.license?.id) { + +

License: 

+ {{ item()?.license?.name }} +
+ } + + @if (item()?.registrationTemplate) {

- Registration Template: {{ item().registrationTemplate }} + Registration Template: {{ item()?.registrationTemplate }}

} - @if (item().provider) { + @if (item()?.provider?.id) {

Provider: 

- {{ item().provider?.name }} + {{ item()?.provider?.name }}
} - @if (item().conflictOfInterestResponse) { + @if ( + item()?.conflictOfInterestResponse && + item()?.conflictOfInterestResponse === "no-conflict-of-interest" + ) {

- Conflict of Interest response: - {{ item().conflictOfInterestResponse }} + Conflict of Interest response: Author asserted no Conflict of + Interest

} - @if (item().resourceType !== ResourceType.User) { + @if (item()?.resourceType !== ResourceType.Agent && item()?.id) {

URL: 

- {{ item().id }} + {{ item()?.id }}
} - @if (item().doi) { + @if (item()?.doi) {

DOI: 

- {{ item().doi }} + {{ item()?.doi }}
} - @if (item().resourceType === ResourceType.User) { -

Public projects: {{ item().publicProjects }}

-

Public registrations: {{ item().publicRegistrations }}

-

Public preprints: {{ item().publicPreprints }}

+ @if (item()?.resourceType === ResourceType.Agent) { + @if (loading) { + + + + } @else { +

Public projects: {{ item()?.publicProjects ?? 0 }}

+

+ Public registrations: {{ item()?.publicRegistrations ?? 0 }} +

+

Public preprints: {{ item()?.publicPreprints ?? 0 }}

+ } } - @if (item().employment) { -

Employment: {{ item().employment }}

+ @if (item()?.employment) { +

Employment: {{ item()?.employment }}

} - @if (item().education) { -

Education: {{ item().education }}

+ @if (item()?.education) { +

Education: {{ item()?.education }}

}
diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.scss b/src/app/shared/components/resources/resource-card/resource-card.component.scss index 508b4f1b1..d7aae7722 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.scss +++ b/src/app/shared/components/resources/resource-card/resource-card.component.scss @@ -13,6 +13,7 @@ font-size: 1.4rem; line-height: 1.7rem; color: var.$dark-blue-1; + padding-bottom: 4px; &:hover { text-decoration: underline; } diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.ts b/src/app/shared/components/resources/resource-card/resource-card.component.ts index 896cc8ac2..eb10edbc7 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.ts +++ b/src/app/shared/components/resources/resource-card/resource-card.component.ts @@ -1,4 +1,9 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + model, +} from '@angular/core'; import { Accordion, AccordionContent, @@ -8,6 +13,9 @@ import { import { DatePipe, NgOptimizedImage } from '@angular/common'; import { ResourceType } from '@osf/features/search/models/resource-type.enum'; import { Resource } from '@osf/features/search/models/resource.entity'; +import { ResourceCardService } from '@shared/components/resources/resource-card/resource-card.service'; +import { finalize } from 'rxjs'; +import { Skeleton } from 'primeng/skeleton'; @Component({ selector: 'osf-resource-card', @@ -18,13 +26,47 @@ import { Resource } from '@osf/features/search/models/resource.entity'; AccordionPanel, DatePipe, NgOptimizedImage, + Skeleton, ], templateUrl: './resource-card.component.html', styleUrl: './resource-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResourceCardComponent { - item = input.required(); + item = model(undefined); + readonly #resourceCardService = inject(ResourceCardService); + loading = false; + dataIsLoaded = false; protected readonly ResourceType = ResourceType; + + onOpen() { + if (this.item() && !this.dataIsLoaded) { + const userIri = this.item()?.id.split('/').pop(); + if (userIri) { + this.loading = true; + this.#resourceCardService + .getUserRelatedCounts(userIri) + .pipe( + finalize(() => { + this.loading = false; + this.dataIsLoaded = true; + }), + ) + .subscribe((res) => { + this.item.update( + (current) => + ({ + ...current, + publicProjects: res.projects, + publicPreprints: res.preprints, + publicRegistrations: res.registrations, + education: res.education, + employment: res.employment, + }) as Resource, + ); + }); + } + } + } } diff --git a/src/app/shared/components/resources/resource-card/resource-card.service.ts b/src/app/shared/components/resources/resource-card/resource-card.service.ts new file mode 100644 index 000000000..cf6a74583 --- /dev/null +++ b/src/app/shared/components/resources/resource-card/resource-card.service.ts @@ -0,0 +1,24 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { map, Observable } from 'rxjs'; +import { environment } from '../../../../../environments/environment'; +import { UserCountsResponse } from '@shared/components/resources/resource-card/models/user-counts-response.entity'; +import { MapUserCounts } from '@shared/components/resources/resource-card/mappers/user-counts.mapper'; +import { UserRelatedDataCounts } from '@shared/components/resources/resource-card/models/user-related-data-counts.entity'; + +@Injectable({ + providedIn: 'root', +}) +export class ResourceCardService { + #jsonApiService = inject(JsonApiService); + + getUserRelatedCounts(userIri: string): Observable { + const params: Record = { + related_counts: 'nodes,registrations,preprints', + }; + + return this.#jsonApiService + .get(`${environment.apiUrl}/users/${userIri}`, params) + .pipe(map((response) => MapUserCounts(response))); + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.html new file mode 100644 index 000000000..43e89df69 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.html @@ -0,0 +1,16 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.ts new file mode 100644 index 000000000..98279b7bc --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/creators/creators-filter.component.ts @@ -0,0 +1,102 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + OnDestroy, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { debounceTime, distinctUntilChanged, Subject, takeUntil } from 'rxjs'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetCreator, +} from '@shared/components/resources/resource-filters/store'; +import { + GetAllOptions, + GetCreatorsOptions, +} from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; +import { toObservable } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'osf-creators-filter', + imports: [Select, ReactiveFormsModule, FormsModule], + templateUrl: './creators-filter.component.html', + styleUrl: './creators-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CreatorsFilterComponent implements OnDestroy { + readonly #store = inject(Store); + + protected searchCreatorsResults = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getCreators, + ); + protected creatorsOptions = computed(() => { + return this.searchCreatorsResults().map((creator) => ({ + label: creator.name, + id: creator.id, + })); + }); + protected creatorsLoading = signal(false); + protected creatorState = this.#store.selectSignal( + ResourceFiltersSelectors.getCreator, + ); + readonly #unsubscribe = new Subject(); + protected creatorsInput = signal(null); + protected initialization = true; + + constructor() { + toObservable(this.creatorsInput) + .pipe( + debounceTime(500), + distinctUntilChanged(), + takeUntil(this.#unsubscribe), + ) + .subscribe((searchText) => { + if (!this.initialization) { + if (searchText) { + this.#store.dispatch(new GetCreatorsOptions(searchText ?? '')); + } + + if (!searchText) { + this.#store.dispatch(new SetCreator('', '')); + this.#store.dispatch(GetAllOptions); + } + } else { + this.initialization = false; + } + }); + + effect(() => { + const storeValue = this.creatorState().label; + const currentInput = untracked(() => this.creatorsInput()); + + if (!storeValue && currentInput !== null) { + this.creatorsInput.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.creatorsInput.set(storeValue); + } + }); + } + + ngOnDestroy() { + this.#unsubscribe.complete(); + } + + setCreator(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const creator = this.creatorsOptions().find((p) => + p.label.includes(event.value), + ); + if (creator) { + this.#store.dispatch(new SetCreator(creator.label, creator.id)); + this.#store.dispatch(GetAllOptions); + } + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.html new file mode 100644 index 000000000..b6188ece4 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.html @@ -0,0 +1,13 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.spec.ts new file mode 100644 index 000000000..2966937b3 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DateCreatedFilterComponent } from './date-created-filter.component'; + +describe('DateCreatedFilterComponent', () => { + let component: DateCreatedFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DateCreatedFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DateCreatedFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.ts new file mode 100644 index 000000000..01f933441 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/date-created/date-created-filter.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetDateCreated, +} from '@shared/components/resources/resource-filters/store'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-date-created-filter', + imports: [ReactiveFormsModule, Select, FormsModule], + templateUrl: './date-created-filter.component.html', + styleUrl: './date-created-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DateCreatedFilterComponent { + readonly #store = inject(Store); + + protected availableDates = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getDatesCreated, + ); + protected dateCreatedState = this.#store.selectSignal( + ResourceFiltersSelectors.getDateCreated, + ); + protected inputDate = signal(null); + protected datesOptions = computed(() => { + return this.availableDates().map((date) => ({ + label: date.value + ' (' + date.count + ')', + value: date.value, + })); + }); + + constructor() { + effect(() => { + const storeValue = this.dateCreatedState().label; + const currentInput = untracked(() => this.inputDate()); + + if (!storeValue && currentInput !== null) { + this.inputDate.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputDate.set(storeValue); + } + }); + } + + setDateCreated(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId) { + this.#store.dispatch(new SetDateCreated(event.value)); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.html new file mode 100644 index 000000000..fd813ff38 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.html @@ -0,0 +1,19 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.spec.ts new file mode 100644 index 000000000..1cda6a9b4 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FunderFilterComponent } from './funder-filter.component'; + +describe('FunderComponent', () => { + let component: FunderFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FunderFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FunderFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.ts new file mode 100644 index 000000000..4c8e077c1 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/funder/funder-filter.component.ts @@ -0,0 +1,87 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetFunder, +} from '@shared/components/resources/resource-filters/store'; +import { FormsModule } from '@angular/forms'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-funder-filter', + imports: [Select, FormsModule], + templateUrl: './funder-filter.component.html', + styleUrl: './funder-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FunderFilterComponent { + readonly #store = inject(Store); + + protected funderState = this.#store.selectSignal( + ResourceFiltersSelectors.getFunder, + ); + protected availableFunders = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getFunders, + ); + protected inputText = signal(null); + protected fundersOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableFunders() + .filter((funder) => funder.label.toLowerCase().includes(search)) + .map((funder) => ({ + labelCount: funder.label + ' (' + funder.count + ')', + label: funder.label, + id: funder.id, + })); + } + + const res = this.availableFunders().map((funder) => ({ + labelCount: funder.label + ' (' + funder.count + ')', + label: funder.label, + id: funder.id, + })); + + return res; + }); + + constructor() { + effect(() => { + const storeValue = this.funderState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + loading = signal(false); + + setFunders(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const funder = this.fundersOptions()?.find((funder) => + funder.label.includes(event.value), + ); + if (funder) { + this.#store.dispatch(new SetFunder(funder.label, funder.id)); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetFunder('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.html new file mode 100644 index 000000000..684603584 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.scss new file mode 100644 index 000000000..5fd36a5f1 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.scss @@ -0,0 +1,5 @@ +:host ::ng-deep { + .p-scroller-viewport { + flex: none; + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.spec.ts new file mode 100644 index 000000000..a6e5dab90 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionFilterComponent } from './institution-filter.component'; + +describe('InstitutionFilterComponent', () => { + let component: InstitutionFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.ts new file mode 100644 index 000000000..fdd5fe7e4 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component.ts @@ -0,0 +1,91 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetInstitution, +} from '@shared/components/resources/resource-filters/store'; +import { FormsModule } from '@angular/forms'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-institution-filter', + imports: [Select, FormsModule], + templateUrl: './institution-filter.component.html', + styleUrl: './institution-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionFilterComponent { + readonly #store = inject(Store); + + protected institutionState = this.#store.selectSignal( + ResourceFiltersSelectors.getInstitution, + ); + protected availableInstitutions = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getInstitutions, + ); + protected inputText = signal(null); + protected institutionsOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableInstitutions() + .filter((institution) => + institution.label.toLowerCase().includes(search), + ) + .map((institution) => ({ + labelCount: institution.label + ' (' + institution.count + ')', + label: institution.label, + id: institution.id, + })); + } + + const res = this.availableInstitutions().map((institution) => ({ + labelCount: institution.label + ' (' + institution.count + ')', + label: institution.label, + id: institution.id, + })); + + return res; + }); + + constructor() { + effect(() => { + const storeValue = this.institutionState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + loading = signal(false); + + setInstitutions(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const institution = this.institutionsOptions()?.find((institution) => + institution.label.includes(event.value), + ); + if (institution) { + this.#store.dispatch( + new SetInstitution(institution.label, institution.id), + ); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetInstitution('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.html new file mode 100644 index 000000000..2fbbb2b18 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.spec.ts new file mode 100644 index 000000000..655bef1ef --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LicenseFilterComponent } from './license-filter.component'; + +describe('LicenseFilterComponent', () => { + let component: LicenseFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LicenseFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(LicenseFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.ts new file mode 100644 index 000000000..7f191ecea --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/license-filter/license-filter.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { FormsModule } from '@angular/forms'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetLicense, +} from '@shared/components/resources/resource-filters/store'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-license-filter', + imports: [Select, FormsModule], + templateUrl: './license-filter.component.html', + styleUrl: './license-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LicenseFilterComponent { + readonly #store = inject(Store); + + protected availableLicenses = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getLicenses, + ); + protected licenseState = this.#store.selectSignal( + ResourceFiltersSelectors.getLicense, + ); + protected inputText = signal(null); + protected licensesOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableLicenses() + .filter((license) => license.label.toLowerCase().includes(search)) + .map((license) => ({ + labelCount: license.label + ' (' + license.count + ')', + label: license.label, + id: license.id, + })); + } + + return this.availableLicenses().map((license) => ({ + labelCount: license.label + ' (' + license.count + ')', + label: license.label, + id: license.id, + })); + }); + + loading = signal(false); + + constructor() { + effect(() => { + const storeValue = this.licenseState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + setLicenses(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const license = this.licensesOptions().find((license) => + license.label.includes(event.value), + ); + if (license) { + this.#store.dispatch(new SetLicense(license.label, license.id)); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetLicense('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.html new file mode 100644 index 000000000..b8409b2d8 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.html @@ -0,0 +1,19 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts new file mode 100644 index 000000000..9613b6ae8 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PartOfCollectionFilterComponent } from './part-of-collection-filter.component'; + +describe('PartOfCollectionFilterComponent', () => { + let component: PartOfCollectionFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PartOfCollectionFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PartOfCollectionFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.ts new file mode 100644 index 000000000..b969ca804 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetPartOfCollection, +} from '@shared/components/resources/resource-filters/store'; +import { FormsModule } from '@angular/forms'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-part-of-collection-filter', + imports: [Select, FormsModule], + templateUrl: './part-of-collection-filter.component.html', + styleUrl: './part-of-collection-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PartOfCollectionFilterComponent { + readonly #store = inject(Store); + + protected availablePartOfCollections = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getPartOfCollection, + ); + protected partOfCollectionState = this.#store.selectSignal( + ResourceFiltersSelectors.getPartOfCollection, + ); + protected inputText = signal(null); + protected partOfCollectionsOptions = computed(() => { + return this.availablePartOfCollections().map((partOfCollection) => ({ + labelCount: partOfCollection.label + ' (' + partOfCollection.count + ')', + label: partOfCollection.label, + id: partOfCollection.id, + })); + }); + + loading = signal(false); + + constructor() { + effect(() => { + const storeValue = this.partOfCollectionState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + setPartOfCollections(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const part = this.partOfCollectionsOptions().find((p) => + p.label.includes(event.value), + ); + if (part) { + this.#store.dispatch(new SetPartOfCollection(part.label, part.id)); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetPartOfCollection('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.html new file mode 100644 index 000000000..0c2f0e870 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.spec.ts new file mode 100644 index 000000000..f52b1f8a4 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProviderFilterComponent } from './provider-filter.component'; + +describe('ProviderFilterComponent', () => { + let component: ProviderFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProviderFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ProviderFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.ts new file mode 100644 index 000000000..80cc220a9 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetProvider, +} from '@shared/components/resources/resource-filters/store'; +import { FormsModule } from '@angular/forms'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-provider-filter', + imports: [Select, FormsModule], + templateUrl: './provider-filter.component.html', + styleUrl: './provider-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProviderFilterComponent { + readonly #store = inject(Store); + + protected availableProviders = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getProviders, + ); + protected providerState = this.#store.selectSignal( + ResourceFiltersSelectors.getProvider, + ); + protected inputText = signal(null); + protected providersOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableProviders() + .filter((provider) => provider.label.toLowerCase().includes(search)) + .map((provider) => ({ + labelCount: provider.label + ' (' + provider.count + ')', + label: provider.label, + id: provider.id, + })); + } + + return this.availableProviders().map((provider) => ({ + labelCount: provider.label + ' (' + provider.count + ')', + label: provider.label, + id: provider.id, + })); + }); + + loading = signal(false); + + constructor() { + effect(() => { + const storeValue = this.providerState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + setProviders(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const provider = this.providersOptions().find((p) => + p.label.includes(event.value), + ); + if (provider) { + this.#store.dispatch(new SetProvider(provider.label, provider.id)); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetProvider('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.html new file mode 100644 index 000000000..89d44409f --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.spec.ts new file mode 100644 index 000000000..ed9fb6d63 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourceTypeFilterComponent } from './resource-type-filter.component'; + +describe('ResourceTypeFilterComponent', () => { + let component: ResourceTypeFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceTypeFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourceTypeFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.ts new file mode 100644 index 000000000..be0039266 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component.ts @@ -0,0 +1,89 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetResourceType, +} from '@shared/components/resources/resource-filters/store'; +import { FormsModule } from '@angular/forms'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-resource-type-filter', + imports: [Select, FormsModule], + templateUrl: './resource-type-filter.component.html', + styleUrl: './resource-type-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourceTypeFilterComponent { + readonly #store = inject(Store); + + protected availableResourceTypes = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getResourceTypes, + ); + protected resourceTypeState = this.#store.selectSignal( + ResourceFiltersSelectors.getResourceType, + ); + protected inputText = signal(null); + protected resourceTypesOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableResourceTypes() + .filter((resourceType) => + resourceType.label.toLowerCase().includes(search), + ) + .map((resourceType) => ({ + labelCount: resourceType.label + ' (' + resourceType.count + ')', + label: resourceType.label, + id: resourceType.id, + })); + } + + return this.availableResourceTypes().map((resourceType) => ({ + labelCount: resourceType.label + ' (' + resourceType.count + ')', + label: resourceType.label, + id: resourceType.id, + })); + }); + + loading = signal(false); + + constructor() { + effect(() => { + const storeValue = this.resourceTypeState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + setResourceTypes(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const resourceType = this.resourceTypesOptions().find((p) => + p.label.includes(event.value), + ); + if (resourceType) { + this.#store.dispatch( + new SetResourceType(resourceType.label, resourceType.id), + ); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetResourceType('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.actions.ts b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.actions.ts new file mode 100644 index 000000000..5f7687cfe --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.actions.ts @@ -0,0 +1,42 @@ +export class GetCreatorsOptions { + static readonly type = '[Resource Filters Options] Get Creators'; + + constructor(public searchName: string) {} +} + +export class GetDatesCreatedOptions { + static readonly type = '[Resource Filters Options] Get Dates Created'; +} + +export class GetFundersOptions { + static readonly type = '[Resource Filters Options] Get Funders'; +} + +export class GetSubjectsOptions { + static readonly type = '[Resource Filters Options] Get Subjects'; +} + +export class GetLicensesOptions { + static readonly type = '[Resource Filters Options] Get Licenses'; +} + +export class GetResourceTypesOptions { + static readonly type = '[Resource Filters Options] Get Resource Types'; +} + +export class GetInstitutionsOptions { + static readonly type = '[Resource Filters Options] Get Institutions'; +} + +export class GetProvidersOptions { + static readonly type = '[Resource Filters Options] Get Providers'; +} + +export class GetPartOfCollectionOptions { + static readonly type = + '[Resource Filters Options] Get Part Of Collection Options'; +} + +export class GetAllOptions { + static readonly type = '[Resource Filters Options] Get All Options'; +} diff --git a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.model.ts b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.model.ts new file mode 100644 index 000000000..10fdea660 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.model.ts @@ -0,0 +1,21 @@ +import { Creator } from '@shared/components/resources/resource-filters/models/creator/creator.entity'; +import { DateCreated } from '@shared/components/resources/resource-filters/models/dateCreated/date-created.entity'; +import { FunderFilter } from '@shared/components/resources/resource-filters/models/funder/funder-filter.entity'; +import { SubjectFilter } from '@shared/components/resources/resource-filters/models/subject/subject-filter.entity'; +import { LicenseFilter } from '@shared/components/resources/resource-filters/models/license/license-filter.entity'; +import { ResourceTypeFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type.entity'; +import { ProviderFilter } from '@shared/components/resources/resource-filters/models/provider/provider-filter.entity'; +import { PartOfCollectionFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity'; +import { InstitutionFilter } from '@shared/components/resources/resource-filters/models/institution/institution-filter.entity'; + +export interface ResourceFiltersOptionsStateModel { + creators: Creator[]; + datesCreated: DateCreated[]; + funders: FunderFilter[]; + subjects: SubjectFilter[]; + licenses: LicenseFilter[]; + resourceTypes: ResourceTypeFilter[]; + institutions: InstitutionFilter[]; + providers: ProviderFilter[]; + partOfCollection: PartOfCollectionFilter[]; +} diff --git a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts new file mode 100644 index 000000000..54d87a027 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts @@ -0,0 +1,69 @@ +import { Selector } from '@ngxs/store'; +import { ResourceFiltersOptionsStateModel } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.model'; +import { ResourceFiltersOptionsState } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.state'; +import { Creator } from '@shared/components/resources/resource-filters/models/creator/creator.entity'; +import { DateCreated } from '@shared/components/resources/resource-filters/models/dateCreated/date-created.entity'; +import { FunderFilter } from '@shared/components/resources/resource-filters/models/funder/funder-filter.entity'; +import { SubjectFilter } from '@shared/components/resources/resource-filters/models/subject/subject-filter.entity'; +import { LicenseFilter } from '@shared/components/resources/resource-filters/models/license/license-filter.entity'; +import { ResourceTypeFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type.entity'; +import { ProviderFilter } from '@shared/components/resources/resource-filters/models/provider/provider-filter.entity'; +import { PartOfCollectionFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity'; +import { InstitutionFilter } from '@shared/components/resources/resource-filters/models/institution/institution-filter.entity'; + +export class ResourceFiltersOptionsSelectors { + @Selector([ResourceFiltersOptionsState]) + static getCreators(state: ResourceFiltersOptionsStateModel): Creator[] { + return state.creators; + } + + @Selector([ResourceFiltersOptionsState]) + static getDatesCreated( + state: ResourceFiltersOptionsStateModel, + ): DateCreated[] { + return state.datesCreated; + } + + @Selector([ResourceFiltersOptionsState]) + static getFunders(state: ResourceFiltersOptionsStateModel): FunderFilter[] { + return state.funders; + } + + @Selector([ResourceFiltersOptionsState]) + static getSubjects(state: ResourceFiltersOptionsStateModel): SubjectFilter[] { + return state.subjects; + } + + @Selector([ResourceFiltersOptionsState]) + static getLicenses(state: ResourceFiltersOptionsStateModel): LicenseFilter[] { + return state.licenses; + } + + @Selector([ResourceFiltersOptionsState]) + static getResourceTypes( + state: ResourceFiltersOptionsStateModel, + ): ResourceTypeFilter[] { + return state.resourceTypes; + } + + @Selector([ResourceFiltersOptionsState]) + static getInstitutions( + state: ResourceFiltersOptionsStateModel, + ): InstitutionFilter[] { + return state.institutions; + } + + @Selector([ResourceFiltersOptionsState]) + static getProviders( + state: ResourceFiltersOptionsStateModel, + ): ProviderFilter[] { + return state.providers; + } + + @Selector([ResourceFiltersOptionsState]) + static getPartOfCollection( + state: ResourceFiltersOptionsStateModel, + ): PartOfCollectionFilter[] { + return state.partOfCollection; + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.state.ts b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.state.ts new file mode 100644 index 000000000..47ab85921 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.state.ts @@ -0,0 +1,137 @@ +import { Action, State, StateContext, Store } from '@ngxs/store'; +import { inject, Injectable } from '@angular/core'; +import { tap } from 'rxjs'; +import { ResourceFiltersOptionsStateModel } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.model'; +import { ResourceFiltersService } from '@shared/components/resources/resource-filters/resource-filters.service'; +import { + GetAllOptions, + GetCreatorsOptions, + GetDatesCreatedOptions, + GetFundersOptions, + GetInstitutionsOptions, + GetLicensesOptions, + GetPartOfCollectionOptions, + GetProvidersOptions, + GetResourceTypesOptions, + GetSubjectsOptions, +} from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; + +@State({ + name: 'resourceFiltersOptions', + defaults: { + creators: [], + datesCreated: [], + funders: [], + subjects: [], + licenses: [], + resourceTypes: [], + institutions: [], + providers: [], + partOfCollection: [], + }, +}) +@Injectable() +export class ResourceFiltersOptionsState { + readonly #store = inject(Store); + readonly #resourceFiltersService = inject(ResourceFiltersService); + + @Action(GetCreatorsOptions) + getProjects( + ctx: StateContext, + action: GetCreatorsOptions, + ) { + if (!action.searchName) { + ctx.patchState({ creators: [] }); + return []; + } + + return this.#resourceFiltersService.getCreators(action.searchName).pipe( + tap((creators) => { + ctx.patchState({ creators: creators }); + }), + ); + } + + @Action(GetDatesCreatedOptions) + getDatesCreated(ctx: StateContext) { + return this.#resourceFiltersService.getDates().pipe( + tap((datesCreated) => { + ctx.patchState({ datesCreated: datesCreated }); + }), + ); + } + + @Action(GetFundersOptions) + getFunders(ctx: StateContext) { + return this.#resourceFiltersService.getFunders().pipe( + tap((funders) => { + ctx.patchState({ funders: funders }); + }), + ); + } + + @Action(GetSubjectsOptions) + getSubjects(ctx: StateContext) { + return this.#resourceFiltersService.getSubjects().pipe( + tap((subjects) => { + ctx.patchState({ subjects: subjects }); + }), + ); + } + + @Action(GetLicensesOptions) + getLicenses(ctx: StateContext) { + return this.#resourceFiltersService.getLicenses().pipe( + tap((licenses) => { + ctx.patchState({ licenses: licenses }); + }), + ); + } + + @Action(GetResourceTypesOptions) + getResourceTypes(ctx: StateContext) { + return this.#resourceFiltersService.getResourceTypes().pipe( + tap((resourceTypes) => { + ctx.patchState({ resourceTypes: resourceTypes }); + }), + ); + } + + @Action(GetInstitutionsOptions) + getInstitutions(ctx: StateContext) { + return this.#resourceFiltersService.getInstitutions().pipe( + tap((institutions) => { + ctx.patchState({ institutions: institutions }); + }), + ); + } + + @Action(GetProvidersOptions) + getProviders(ctx: StateContext) { + return this.#resourceFiltersService.getProviders().pipe( + tap((providers) => { + ctx.patchState({ providers: providers }); + }), + ); + } + @Action(GetPartOfCollectionOptions) + getPartOfCollection(ctx: StateContext) { + return this.#resourceFiltersService.getPartOtCollections().pipe( + tap((partOfCollection) => { + ctx.patchState({ partOfCollection: partOfCollection }); + }), + ); + } + + @Action(GetAllOptions) + getAllOptions() { + this.#store.dispatch(GetDatesCreatedOptions); + this.#store.dispatch(GetFundersOptions); + this.#store.dispatch(GetSubjectsOptions); + this.#store.dispatch(GetLicensesOptions); + this.#store.dispatch(GetResourceTypesOptions); + this.#store.dispatch(GetInstitutionsOptions); + this.#store.dispatch(GetProvidersOptions); + this.#store.dispatch(GetPartOfCollectionOptions); + } +} diff --git a/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.html b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.html new file mode 100644 index 000000000..dd18db123 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.scss b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.spec.ts b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.spec.ts new file mode 100644 index 000000000..fd18cc1f2 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubjectFilterComponent } from './subject-filter.component'; + +describe('SubjectComponent', () => { + let component: SubjectFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SubjectFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SubjectFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.ts b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.ts new file mode 100644 index 000000000..388825cd0 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/filters/subject/subject-filter.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Select, SelectChangeEvent } from 'primeng/select'; +import { Store } from '@ngxs/store'; +import { + ResourceFiltersSelectors, + SetSubject, +} from '@shared/components/resources/resource-filters/store'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; + +@Component({ + selector: 'osf-subject-filter', + imports: [Select, FormsModule], + templateUrl: './subject-filter.component.html', + styleUrl: './subject-filter.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SubjectFilterComponent { + readonly #store = inject(Store); + + protected availableSubjects = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getSubjects, + ); + protected subjectState = this.#store.selectSignal( + ResourceFiltersSelectors.getSubject, + ); + protected inputText = signal(null); + protected subjectsOptions = computed(() => { + if (this.inputText() !== null) { + const search = this.inputText()!.toLowerCase(); + return this.availableSubjects() + .filter((subject) => subject.label.toLowerCase().includes(search)) + .map((subject) => ({ + labelCount: subject.label + ' (' + subject.count + ')', + label: subject.label, + id: subject.id, + })); + } + + return this.availableSubjects().map((subject) => ({ + labelCount: subject.label + ' (' + subject.count + ')', + label: subject.label, + id: subject.id, + })); + }); + + loading = signal(false); + + constructor() { + effect(() => { + const storeValue = this.subjectState().label; + const currentInput = untracked(() => this.inputText()); + + if (!storeValue && currentInput !== null) { + this.inputText.set(null); + } else if (storeValue && currentInput !== storeValue) { + this.inputText.set(storeValue); + } + }); + } + + setSubject(event: SelectChangeEvent): void { + if ((event.originalEvent as PointerEvent).pointerId && event.value) { + const subject = this.subjectsOptions().find((p) => + p.label.includes(event.value), + ); + if (subject) { + this.#store.dispatch(new SetSubject(subject.label, subject.id)); + this.#store.dispatch(GetAllOptions); + } + } else { + this.#store.dispatch(new SetSubject('', '')); + this.#store.dispatch(GetAllOptions); + } + } +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/creators/creators.mappers.ts b/src/app/shared/components/resources/resource-filters/mappers/creators/creators.mappers.ts new file mode 100644 index 000000000..c8bed480d --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/creators/creators.mappers.ts @@ -0,0 +1,9 @@ +import { CreatorItem } from '@shared/components/resources/resource-filters/models/creator/creator-item.entity'; +import { Creator } from '@shared/components/resources/resource-filters/models/creator/creator.entity'; + +export function MapCreators(rawItem: CreatorItem): Creator { + return { + id: rawItem?.['@id'], + name: rawItem?.name?.[0]?.['@value'], + }; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/dateCreated/date-created.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/dateCreated/date-created.mapper.ts new file mode 100644 index 000000000..426ca8270 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/dateCreated/date-created.mapper.ts @@ -0,0 +1,26 @@ +import { DateCreated } from '@shared/components/resources/resource-filters/models/dateCreated/date-created.entity'; +import { IndexValueSearch } from '@shared/components/resources/resource-filters/models/index-value-search.entity'; +import { IndexCardFilter } from '@shared/components/resources/resource-filters/models/index-card-filter.entity'; + +export function MapDateCreated(items: IndexValueSearch[]): DateCreated[] { + const datesCreated: DateCreated[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + datesCreated.push({ + value: (indexCard as IndexCardFilter).attributes.resourceMetadata + .displayLabel[0]['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return datesCreated; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/funder/funder.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/funder/funder.mapper.ts new file mode 100644 index 000000000..ffb277305 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/funder/funder.mapper.ts @@ -0,0 +1,29 @@ +import { FunderIndexValueSearch } from '@shared/components/resources/resource-filters/models/funder/funder-index-value-search.entity'; +import { FunderFilter } from '@shared/components/resources/resource-filters/models/funder/funder-filter.entity'; +import { FunderIndexCardFilter } from '@shared/components/resources/resource-filters/models/funder/funder-index-card-filter.entity'; + +export function MapFunders(items: FunderIndexValueSearch[]): FunderFilter[] { + const funders: FunderFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + funders.push({ + id: (indexCard as FunderIndexCardFilter).attributes.resourceMetadata?.[ + '@id' + ], + label: (indexCard as FunderIndexCardFilter).attributes.resourceMetadata + ?.name?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return funders; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/institution/institution.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/institution/institution.mapper.ts new file mode 100644 index 000000000..cfa6dd739 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/institution/institution.mapper.ts @@ -0,0 +1,30 @@ +import { InstitutionIndexValueSearch } from '@shared/components/resources/resource-filters/models/institution/institution-index-value-search.entity'; +import { InstitutionIndexCardFilter } from '@shared/components/resources/resource-filters/models/institution/institution-index-card-filter.entity'; +import { InstitutionFilter } from '@shared/components/resources/resource-filters/models/institution/institution-filter.entity'; + +export function MapInstitutions( + items: InstitutionIndexValueSearch[], +): InstitutionFilter[] { + const institutions: InstitutionFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + institutions.push({ + id: (indexCard as InstitutionIndexCardFilter).attributes + .resourceMetadata?.['@id'], + label: (indexCard as InstitutionIndexCardFilter).attributes + .resourceMetadata?.name?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return institutions; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/license/license.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/license/license.mapper.ts new file mode 100644 index 000000000..e5f8b1ec6 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/license/license.mapper.ts @@ -0,0 +1,29 @@ +import { LicenseIndexValueSearch } from '@shared/components/resources/resource-filters/models/license/license-index-value-search.entity'; +import { LicenseIndexCardFilter } from '@shared/components/resources/resource-filters/models/license/license-index-card-filter.entity'; +import { LicenseFilter } from '@shared/components/resources/resource-filters/models/license/license-filter.entity'; + +export function MapLicenses(items: LicenseIndexValueSearch[]): LicenseFilter[] { + const licenses: LicenseFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + licenses.push({ + id: (indexCard as LicenseIndexCardFilter).attributes.resourceMetadata?.[ + '@id' + ], + label: (indexCard as LicenseIndexCardFilter).attributes.resourceMetadata + ?.name?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return licenses; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/part-of-collection/part-of-collection.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/part-of-collection/part-of-collection.mapper.ts new file mode 100644 index 000000000..e3d967844 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/part-of-collection/part-of-collection.mapper.ts @@ -0,0 +1,30 @@ +import { PartOfCollectionIndexValueSearch } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-value-search.entity'; +import { PartOfCollectionFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity'; +import { PartOfCollectionIndexCardFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-card-filter.entity'; + +export function MapPartOfCollections( + items: PartOfCollectionIndexValueSearch[], +): PartOfCollectionFilter[] { + const partOfCollections: PartOfCollectionFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + partOfCollections.push({ + id: (indexCard as PartOfCollectionIndexCardFilter).attributes + .resourceMetadata?.['@id'], + label: (indexCard as PartOfCollectionIndexCardFilter).attributes + .resourceMetadata?.title?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return partOfCollections; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/provider/provider.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/provider/provider.mapper.ts new file mode 100644 index 000000000..981f21e56 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/provider/provider.mapper.ts @@ -0,0 +1,30 @@ +import { ProviderIndexValueSearch } from '@shared/components/resources/resource-filters/models/provider/provider-index-value-search.entity'; +import { ProviderFilter } from '@shared/components/resources/resource-filters/models/provider/provider-filter.entity'; +import { ProviderIndexCardFilter } from '@shared/components/resources/resource-filters/models/provider/provider-index-card-filter.entity'; + +export function MapProviders( + items: ProviderIndexValueSearch[], +): ProviderFilter[] { + const providers: ProviderFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + providers.push({ + id: (indexCard as ProviderIndexCardFilter).attributes + .resourceMetadata?.['@id'], + label: (indexCard as ProviderIndexCardFilter).attributes + .resourceMetadata?.name?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return providers; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/resource-type/resource-type.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/resource-type/resource-type.mapper.ts new file mode 100644 index 000000000..bd76a7cd5 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/resource-type/resource-type.mapper.ts @@ -0,0 +1,30 @@ +import { ResourceTypeIndexValueSearch } from '@shared/components/resources/resource-filters/models/resource-type/resource-type-index-value-search.entity'; +import { ResourceTypeFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type.entity'; +import { ResourceTypeIndexCardFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type-index-card-filter.entity'; + +export function MapResourceType( + items: ResourceTypeIndexValueSearch[], +): ResourceTypeFilter[] { + const resourceTypes: ResourceTypeFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + resourceTypes.push({ + id: (indexCard as ResourceTypeIndexCardFilter).attributes + .resourceMetadata?.['@id'], + label: (indexCard as ResourceTypeIndexCardFilter).attributes + .resourceMetadata?.displayLabel?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return resourceTypes; +} diff --git a/src/app/shared/components/resources/resource-filters/mappers/subject/subject.mapper.ts b/src/app/shared/components/resources/resource-filters/mappers/subject/subject.mapper.ts new file mode 100644 index 000000000..e3e4e6730 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/mappers/subject/subject.mapper.ts @@ -0,0 +1,27 @@ +import { IndexValueSearch } from '@shared/components/resources/resource-filters/models/index-value-search.entity'; +import { SubjectFilter } from '@shared/components/resources/resource-filters/models/subject/subject-filter.entity'; +import { IndexCardFilter } from '@shared/components/resources/resource-filters/models/index-card-filter.entity'; + +export function MapSubject(items: IndexValueSearch[]): SubjectFilter[] { + const subjects: SubjectFilter[] = []; + + if (!items) { + return []; + } + + for (const item of items) { + if (item.type === 'search-result') { + const indexCard = items.find( + (p) => p.id === item.relationships.indexCard.data.id, + ); + subjects.push({ + id: (indexCard as IndexCardFilter).attributes.resourceMetadata?.['@id'], + label: (indexCard as IndexCardFilter).attributes.resourceMetadata + ?.displayLabel?.[0]?.['@value'], + count: item.attributes.cardSearchResultCount, + }); + } + } + + return subjects; +} diff --git a/src/app/shared/components/resources/resource-filters/models/creator/creator-item.entity.ts b/src/app/shared/components/resources/resource-filters/models/creator/creator-item.entity.ts new file mode 100644 index 000000000..b69a75009 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/creator/creator-item.entity.ts @@ -0,0 +1,4 @@ +export interface CreatorItem { + '@id': string; + name: { '@value': string }[]; +} diff --git a/src/app/shared/components/resources/resource-filters/models/creator/creator.entity.ts b/src/app/shared/components/resources/resource-filters/models/creator/creator.entity.ts new file mode 100644 index 000000000..c4ffc7510 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/creator/creator.entity.ts @@ -0,0 +1,4 @@ +export interface Creator { + id: string; + name: string; +} diff --git a/src/app/shared/components/resources/resource-filters/models/dateCreated/date-created.entity.ts b/src/app/shared/components/resources/resource-filters/models/dateCreated/date-created.entity.ts new file mode 100644 index 000000000..8948ebb42 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/dateCreated/date-created.entity.ts @@ -0,0 +1,4 @@ +export interface DateCreated { + value: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/dateCreated/date-index-card.entity.ts b/src/app/shared/components/resources/resource-filters/models/dateCreated/date-index-card.entity.ts new file mode 100644 index 000000000..a522a8ab2 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/dateCreated/date-index-card.entity.ts @@ -0,0 +1,10 @@ +// export interface DateIndexCard { +// attributes: { +// resourceIdentifier: string[]; +// resourceMetadata: { +// displayLabel: { '@value': string }[]; +// } +// }, +// id: string; +// type: 'index-card'; +// } diff --git a/src/app/shared/components/resources/resource-filters/models/filter-labels.ts b/src/app/shared/components/resources/resource-filters/models/filter-labels.ts new file mode 100644 index 000000000..2b620967c --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/filter-labels.ts @@ -0,0 +1,11 @@ +export const FilterLabels = { + creator: 'Creator', + dateCreated: 'Date Created', + funder: 'Funder', + subject: 'Subject', + license: 'License', + resourceType: 'Resource Type', + institution: 'Institution', + provider: 'Provider', + partOfCollection: 'Part of Collection', +}; diff --git a/src/app/shared/components/resources/resource-filters/models/filter-type.enum.ts b/src/app/shared/components/resources/resource-filters/models/filter-type.enum.ts new file mode 100644 index 000000000..6c8c13946 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/filter-type.enum.ts @@ -0,0 +1,11 @@ +export enum FilterType { + Creator, + DateCreated, + Funder, + Subject, + License, + ResourceType, + Institution, + Provider, + PartOfCollection, +} diff --git a/src/app/shared/components/resources/resource-filters/models/funder/funder-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/funder/funder-filter.entity.ts new file mode 100644 index 000000000..35cb97a9f --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/funder/funder-filter.entity.ts @@ -0,0 +1,5 @@ +export interface FunderFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/funder/funder-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/funder/funder-index-card-filter.entity.ts new file mode 100644 index 000000000..6c3052fd2 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/funder/funder-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface FunderIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + name: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/funder/funder-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/funder/funder-index-value-search.entity.ts new file mode 100644 index 000000000..db9e4d4f3 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/funder/funder-index-value-search.entity.ts @@ -0,0 +1,4 @@ +import { FunderIndexCardFilter } from '@shared/components/resources/resource-filters/models/funder/funder-index-card-filter.entity'; +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; + +export type FunderIndexValueSearch = SearchResultCount | FunderIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/index-card-filter.entity.ts new file mode 100644 index 000000000..a40665ab3 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface IndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + displayLabel: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/index-value-search.entity.ts new file mode 100644 index 000000000..2c4f62f0d --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/index-value-search.entity.ts @@ -0,0 +1,4 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { IndexCardFilter } from '@shared/components/resources/resource-filters/models/index-card-filter.entity'; + +export type IndexValueSearch = SearchResultCount | IndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/institution/institution-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/institution/institution-filter.entity.ts new file mode 100644 index 000000000..19b5cb9e9 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/institution/institution-filter.entity.ts @@ -0,0 +1,5 @@ +export interface InstitutionFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/institution/institution-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/institution/institution-index-card-filter.entity.ts new file mode 100644 index 000000000..3cc8a68a3 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/institution/institution-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface InstitutionIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + name: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/institution/institution-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/institution/institution-index-value-search.entity.ts new file mode 100644 index 000000000..bacd74265 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/institution/institution-index-value-search.entity.ts @@ -0,0 +1,6 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { InstitutionIndexCardFilter } from '@shared/components/resources/resource-filters/models/institution/institution-index-card-filter.entity'; + +export type InstitutionIndexValueSearch = + | SearchResultCount + | InstitutionIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/license/license-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/license/license-filter.entity.ts new file mode 100644 index 000000000..79b4c9205 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/license/license-filter.entity.ts @@ -0,0 +1,5 @@ +export interface LicenseFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/license/license-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/license/license-index-card-filter.entity.ts new file mode 100644 index 000000000..818c9d842 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/license/license-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface LicenseIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + name: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/license/license-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/license/license-index-value-search.entity.ts new file mode 100644 index 000000000..d972ddcac --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/license/license-index-value-search.entity.ts @@ -0,0 +1,6 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { LicenseIndexCardFilter } from '@shared/components/resources/resource-filters/models/license/license-index-card-filter.entity'; + +export type LicenseIndexValueSearch = + | SearchResultCount + | LicenseIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity.ts new file mode 100644 index 000000000..c37f0d213 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity.ts @@ -0,0 +1,5 @@ +export interface PartOfCollectionFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-card-filter.entity.ts new file mode 100644 index 000000000..f2e98b9bb --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface PartOfCollectionIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + title: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-value-search.entity.ts new file mode 100644 index 000000000..cbe227b48 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-value-search.entity.ts @@ -0,0 +1,6 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { PartOfCollectionIndexCardFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-card-filter.entity'; + +export type PartOfCollectionIndexValueSearch = + | SearchResultCount + | PartOfCollectionIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/provider/provider-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/provider/provider-filter.entity.ts new file mode 100644 index 000000000..054f75bfa --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/provider/provider-filter.entity.ts @@ -0,0 +1,5 @@ +export interface ProviderFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/provider/provider-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/provider/provider-index-card-filter.entity.ts new file mode 100644 index 000000000..f3e7a4e2b --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/provider/provider-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface ProviderIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + name: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/provider/provider-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/provider/provider-index-value-search.entity.ts new file mode 100644 index 000000000..7e4cfb7a6 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/provider/provider-index-value-search.entity.ts @@ -0,0 +1,6 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { ProviderIndexCardFilter } from '@shared/components/resources/resource-filters/models/provider/provider-index-card-filter.entity'; + +export type ProviderIndexValueSearch = + | SearchResultCount + | ProviderIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-card-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-card-filter.entity.ts new file mode 100644 index 000000000..c588a750c --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-card-filter.entity.ts @@ -0,0 +1,11 @@ +export interface ResourceTypeIndexCardFilter { + attributes: { + resourceIdentifier: string[]; + resourceMetadata: { + displayLabel: { '@value': string }[]; + '@id': string; + }; + }; + id: string; + type: 'index-card'; +} diff --git a/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-value-search.entity.ts b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-value-search.entity.ts new file mode 100644 index 000000000..126e81cfd --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type-index-value-search.entity.ts @@ -0,0 +1,6 @@ +import { SearchResultCount } from '@shared/components/resources/resource-filters/models/search-result-count.entity'; +import { ResourceTypeIndexCardFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type-index-card-filter.entity'; + +export type ResourceTypeIndexValueSearch = + | SearchResultCount + | ResourceTypeIndexCardFilter; diff --git a/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type.entity.ts b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type.entity.ts new file mode 100644 index 000000000..856aa767b --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/resource-type/resource-type.entity.ts @@ -0,0 +1,5 @@ +export interface ResourceTypeFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/models/search-result-count.entity.ts b/src/app/shared/components/resources/resource-filters/models/search-result-count.entity.ts new file mode 100644 index 000000000..ffb0e6e1a --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/search-result-count.entity.ts @@ -0,0 +1,15 @@ +export interface SearchResultCount { + attributes: { + cardSearchResultCount: number; + }; + id: string; + type: 'search-result'; + relationships: { + indexCard: { + data: { + id: string; + type: string; + }; + }; + }; +} diff --git a/src/app/shared/components/resources/resource-filters/models/subject/subject-filter-response.entity.ts b/src/app/shared/components/resources/resource-filters/models/subject/subject-filter-response.entity.ts new file mode 100644 index 000000000..541e3671a --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/subject/subject-filter-response.entity.ts @@ -0,0 +1,5 @@ +// export interface SubjectFilterResponse { +// '@id': string; +// displayLabel: { '@value': string }[]; +// resourceType: { '@id': string }[]; +// } diff --git a/src/app/shared/components/resources/resource-filters/models/subject/subject-filter.entity.ts b/src/app/shared/components/resources/resource-filters/models/subject/subject-filter.entity.ts new file mode 100644 index 000000000..d94e1e63b --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/models/subject/subject-filter.entity.ts @@ -0,0 +1,5 @@ +export interface SubjectFilter { + id: string; + label: string; + count: number; +} diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.html b/src/app/shared/components/resources/resource-filters/resource-filters.component.html index fc35123ae..2371c14fe 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.html +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.html @@ -1,145 +1,82 @@ - - - Creator - -
-

Filter creators by typing their name below

- - [(ngModel)]="creator" [suggestions]="filteredCreators()" - (completeMethod)="setCreator($event)" - -
-
-
+
+ + + Creator + + + + - - Date Created - -
-

- Please select the creation date from the dropdown below -

- - -
-
-
+ @if (datesOptionsCount() > 0) { + + Date Created + + + + + } - - Funder - -
-

- Please select the funder from the dropdown below or start typing for - find it -

- - -
-
-
+ @if (funderOptionsCount() > 0) { + + Funder + + + + + } - - Subject - -
-

- Please select the subject from the dropdown below or start typing for - find it -

- - -
-
-
+ @if (subjectOptionsCount() > 0) { + + Subject + + + + + } - - License - -
-

- Please select the license from the dropdown below or start typing for - find it -

- - -
-
-
+ @if (licenseOptionsCount() > 0) { + + License + + + + + } - - Resource Type - -
-

- Please select the resource type from the dropdown below or start - typing for find it -

- - -
-
-
+ @if (resourceTypeOptionsCount() > 0) { + + Resource Type + + + + + } - - Institution - -
-

- Please select the institution from the dropdown below or start typing - for find it -

- - -
-
-
+ @if (institutionOptionsCount() > 0) { + + Institution + + + + + } - - Provider - -
-

- Please select the provider from the dropdown below or start typing for - find it -

- - -
-
-
+ @if (providerOptionsCount() > 0) { + + Provider + + + + + } - - Part of Collection - -
-

- Please select the part of collection from the dropdown below or start - typing for find it -

- - -
-
-
-
+ @if (partOfCollectionOptionsCount() > 0) { + + Part of Collection + + + + + } + +
diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.scss b/src/app/shared/components/resources/resource-filters/resource-filters.component.scss index 6b72be59f..e73580030 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.scss +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.scss @@ -4,17 +4,5 @@ border: 1px solid var.$grey-2; border-radius: 12px; padding: 0 1.7rem 0 1.7rem; - width: 30%; height: fit-content; - - .content-body { - display: flex; - flex-direction: column; - row-gap: 1.5rem; - padding-bottom: 1.5rem; - - p { - font-weight: 300; - } - } } diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.ts b/src/app/shared/components/resources/resource-filters/resource-filters.component.ts index 4b17d3220..335f592b1 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.ts +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.ts @@ -2,8 +2,9 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, - model, + OnInit, } from '@angular/core'; import { Accordion, @@ -11,13 +12,43 @@ import { AccordionHeader, AccordionPanel, } from 'primeng/accordion'; -import { - AutoComplete, - AutoCompleteCompleteEvent, - AutoCompleteModule, -} from 'primeng/autocomplete'; +import { AutoCompleteModule } from 'primeng/autocomplete'; +import { ReactiveFormsModule } from '@angular/forms'; +import { CreatorsFilterComponent } from '@shared/components/resources/resource-filters/filters/creators/creators-filter.component'; +import { DateCreatedFilterComponent } from '@shared/components/resources/resource-filters/filters/date-created/date-created-filter.component'; +import { SubjectFilterComponent } from '@shared/components/resources/resource-filters/filters/subject/subject-filter.component'; +import { FunderFilterComponent } from '@shared/components/resources/resource-filters/filters/funder/funder-filter.component'; +import { LicenseFilterComponent } from '@shared/components/resources/resource-filters/filters/license-filter/license-filter.component'; +import { ResourceTypeFilterComponent } from '@shared/components/resources/resource-filters/filters/resource-type-filter/resource-type-filter.component'; +import { ProviderFilterComponent } from '@shared/components/resources/resource-filters/filters/provider-filter/provider-filter.component'; +import { PartOfCollectionFilterComponent } from '@shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component'; import { Store } from '@ngxs/store'; -import { SetCreator } from '@shared/components/resources/resource-filters/store/resource-filters.actions'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { InstitutionFilterComponent } from '@shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + ResourceFilterLabel, + ResourceFiltersSelectors, + SetCreator, + SetDateCreated, + SetFunder, + SetInstitution, + SetLicense, + SetPartOfCollection, + SetProvider, + SetResourceType, + SetSubject, +} from '@shared/components/resources/resource-filters/store'; +import { + SearchSelectors, + SetResourceTab, + SetSearchText, + SetSortBy, +} from '@osf/features/search/store'; +import { take } from 'rxjs'; +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; +import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; @Component({ selector: 'osf-resource-filters', @@ -26,25 +57,318 @@ import { SetCreator } from '@shared/components/resources/resource-filters/store/ AccordionContent, AccordionHeader, AccordionPanel, - AutoComplete, AutoCompleteModule, + ReactiveFormsModule, + CreatorsFilterComponent, + DateCreatedFilterComponent, + SubjectFilterComponent, + FunderFilterComponent, + LicenseFilterComponent, + ResourceTypeFilterComponent, + ProviderFilterComponent, + PartOfCollectionFilterComponent, + InstitutionFilterComponent, ], templateUrl: './resource-filters.component.html', styleUrl: './resource-filters.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ResourceFiltersComponent { +export class ResourceFiltersComponent implements OnInit { readonly #store = inject(Store); + readonly #router = inject(Router); + readonly #activeRoute = inject(ActivatedRoute); - creator = model(''); - creators: string[] = []; - filteredCreators = computed(() => { - return this.creators.filter((creator) => - creator.toLowerCase().includes(this.creator().toLowerCase()), - ); + readonly datesOptionsCount = computed(() => { + return this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getDatesCreated)() + .reduce((accumulator, date) => accumulator + date.count, 0); }); - setCreator(event: AutoCompleteCompleteEvent) { - this.#store.dispatch(new SetCreator(event.query)); + readonly funderOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getFunders)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly subjectOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getSubjects)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly licenseOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getLicenses)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly resourceTypeOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getResourceTypes)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly institutionOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getInstitutions)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly providerOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getProviders)() + .reduce((acc, item) => acc + item.count, 0), + ); + + readonly partOfCollectionOptionsCount = computed(() => + this.#store + .selectSignal(ResourceFiltersOptionsSelectors.getPartOfCollection)() + .reduce((acc, item) => acc + item.count, 0), + ); + + creatorSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getCreator, + ); + dateCreatedSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getDateCreated, + ); + funderSelected = this.#store.selectSignal(ResourceFiltersSelectors.getFunder); + subjectSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getSubject, + ); + licenseSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getLicense, + ); + resourceTypeSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getResourceType, + ); + institutionSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getInstitution, + ); + providerSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getProvider, + ); + partOfCollectionSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getPartOfCollection, + ); + sortSelected = this.#store.selectSignal(SearchSelectors.getSortBy); + searchInput = this.#store.selectSignal(SearchSelectors.getSearchText); + resourceTabSelected = this.#store.selectSignal( + SearchSelectors.getResourceTab, + ); + + ngOnInit() { + // set all query parameters from route to store when page is loaded + this.#activeRoute.queryParamMap.pipe(take(1)).subscribe((params) => { + const activeFilters = params.get('activeFilters'); + const filters = activeFilters ? JSON.parse(activeFilters) : []; + const sortBy = params.get('sortBy'); + const search = params.get('search'); + const resourceTab = params.get('resourceTab'); + + const creator = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.creator, + ); + const dateCreated = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'DateCreated', + ); + const funder = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.funder, + ); + const subject = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.subject, + ); + const license = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.license, + ); + const resourceType = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'ResourceType', + ); + const institution = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.institution, + ); + const provider = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.provider, + ); + const partOfCollection = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'PartOfCollection', + ); + + if (creator) { + this.#store.dispatch(new SetCreator(creator.label, creator.value)); + } + if (dateCreated) { + this.#store.dispatch(new SetDateCreated(dateCreated.value)); + } + if (funder) { + this.#store.dispatch(new SetFunder(funder.label, funder.value)); + } + if (subject) { + this.#store.dispatch(new SetSubject(subject.label, subject.value)); + } + if (license) { + this.#store.dispatch(new SetLicense(license.label, license.value)); + } + if (resourceType) { + this.#store.dispatch( + new SetResourceType(resourceType.label, resourceType.value), + ); + } + if (institution) { + this.#store.dispatch( + new SetInstitution(institution.label, institution.value), + ); + } + if (provider) { + this.#store.dispatch(new SetProvider(provider.label, provider.value)); + } + if (partOfCollection) { + this.#store.dispatch( + new SetPartOfCollection( + partOfCollection.label, + partOfCollection.value, + ), + ); + } + + if (sortBy) { + this.#store.dispatch(new SetSortBy(sortBy)); + } + if (search) { + this.#store.dispatch(new SetSearchText(search)); + } + if (resourceTab) { + this.#store.dispatch(new SetResourceTab(+resourceTab)); + } + + this.#store.dispatch(GetAllOptions); + }); + } + + constructor() { + // if new value for some filter was put in store, add it to route + effect(() => this.syncFilterToQuery('Creator', this.creatorSelected())); + effect(() => + this.syncFilterToQuery('DateCreated', this.dateCreatedSelected()), + ); + effect(() => this.syncFilterToQuery('Funder', this.funderSelected())); + effect(() => this.syncFilterToQuery('Subject', this.subjectSelected())); + effect(() => this.syncFilterToQuery('License', this.licenseSelected())); + effect(() => + this.syncFilterToQuery('ResourceType', this.resourceTypeSelected()), + ); + effect(() => + this.syncFilterToQuery('Institution', this.institutionSelected()), + ); + effect(() => this.syncFilterToQuery('Provider', this.providerSelected())); + effect(() => + this.syncFilterToQuery( + 'PartOfCollection', + this.partOfCollectionSelected(), + ), + ); + effect(() => this.syncSortingToQuery(this.sortSelected())); + effect(() => this.syncSearchToQuery(this.searchInput())); + effect(() => this.syncResourceTabToQuery(this.resourceTabSelected())); + } + + syncFilterToQuery(filterName: string, filterValue: ResourceFilterLabel) { + const paramMap = this.#activeRoute.snapshot.queryParamMap; + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + // Read existing parameters + const currentFiltersRaw = paramMap.get('activeFilters'); + + let filters: ResourceFilterLabel[] = []; + + try { + filters = currentFiltersRaw + ? (JSON.parse(currentFiltersRaw) as ResourceFilterLabel[]) + : []; + } catch (e) { + console.error('Invalid activeFilters format in query params', e); + } + + const index = filters.findIndex((f) => f.filterName === filterName); + + const hasValue = !!filterValue?.value; + + // Update activeFilters array + if (!hasValue && index !== -1) { + filters.splice(index, 1); + } else if (hasValue && filterValue?.label && filterValue.value) { + const newFilter = { + filterName, + label: filterValue.label, + value: filterValue.value, + }; + + if (index !== -1) { + filters[index] = newFilter; + } else { + filters.push(newFilter); + } + } + + if (filters.length > 0) { + currentParams['activeFilters'] = JSON.stringify(filters); + } else { + delete currentParams['activeFilters']; + } + + // Navigation + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncSortingToQuery(sortBy: string) { + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (sortBy && sortBy !== '-relevance') { + currentParams['sortBy'] = sortBy; + } else if (sortBy && sortBy === '-relevance') { + delete currentParams['sortBy']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncSearchToQuery(search: string) { + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (search) { + currentParams['search'] = search; + } else { + delete currentParams['search']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncResourceTabToQuery(resourceTab: ResourceTab) { + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (resourceTab) { + currentParams['resourceTab'] = resourceTab; + } else { + delete currentParams['resourceTab']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); } } diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.service.ts b/src/app/shared/components/resources/resource-filters/resource-filters.service.ts new file mode 100644 index 000000000..6c1c2ea30 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/resource-filters.service.ts @@ -0,0 +1,249 @@ +import { inject, Injectable } from '@angular/core'; +import { map, Observable } from 'rxjs'; +import { + ApiData, + JsonApiResponse, +} from '@core/services/json-api/json-api.entity'; +import { environment } from '../../../../../environments/environment'; +import { CreatorItem } from '@shared/components/resources/resource-filters/models/creator/creator-item.entity'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { MapCreators } from '@shared/components/resources/resource-filters/mappers/creators/creators.mappers'; +import { Creator } from '@shared/components/resources/resource-filters/models/creator/creator.entity'; +import { IndexValueSearch } from '@shared/components/resources/resource-filters/models/index-value-search.entity'; +import { MapDateCreated } from '@shared/components/resources/resource-filters/mappers/dateCreated/date-created.mapper'; +import { DateCreated } from '@shared/components/resources/resource-filters/models/dateCreated/date-created.entity'; +import { SubjectFilter } from '@shared/components/resources/resource-filters/models/subject/subject-filter.entity'; +import { MapSubject } from '@shared/components/resources/resource-filters/mappers/subject/subject.mapper'; +import { FunderIndexValueSearch } from '@shared/components/resources/resource-filters/models/funder/funder-index-value-search.entity'; +import { FunderFilter } from '@shared/components/resources/resource-filters/models/funder/funder-filter.entity'; +import { MapFunders } from '@shared/components/resources/resource-filters/mappers/funder/funder.mapper'; +import { LicenseFilter } from '@shared/components/resources/resource-filters/models/license/license-filter.entity'; +import { LicenseIndexValueSearch } from '@shared/components/resources/resource-filters/models/license/license-index-value-search.entity'; +import { MapLicenses } from '@shared/components/resources/resource-filters/mappers/license/license.mapper'; +import { ResourceTypeIndexValueSearch } from '@shared/components/resources/resource-filters/models/resource-type/resource-type-index-value-search.entity'; +import { ResourceTypeFilter } from '@shared/components/resources/resource-filters/models/resource-type/resource-type.entity'; +import { MapResourceType } from '@shared/components/resources/resource-filters/mappers/resource-type/resource-type.mapper'; +import { ProviderFilter } from '@shared/components/resources/resource-filters/models/provider/provider-filter.entity'; +import { MapProviders } from '@shared/components/resources/resource-filters/mappers/provider/provider.mapper'; +import { ProviderIndexValueSearch } from '@shared/components/resources/resource-filters/models/provider/provider-index-value-search.entity'; +import { PartOfCollectionFilter } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-filter.entity'; +import { MapPartOfCollections } from '@shared/components/resources/resource-filters/mappers/part-of-collection/part-of-collection.mapper'; +import { PartOfCollectionIndexValueSearch } from '@shared/components/resources/resource-filters/models/part-of-collection/part-of-collection-index-value-search.entity'; +import { Store } from '@ngxs/store'; +import { addFiltersParams } from '@shared/components/resources/resource-filters/utils/add-filters-params.helper'; +import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; +import { SearchSelectors } from '@osf/features/search/store'; +import { getResourceTypes } from '@osf/features/search/utils/helpers/get-resource-types.helper'; +import { InstitutionIndexValueSearch } from '@shared/components/resources/resource-filters/models/institution/institution-index-value-search.entity'; +import { MapInstitutions } from '@shared/components/resources/resource-filters/mappers/institution/institution.mapper'; + +@Injectable({ + providedIn: 'root', +}) +export class ResourceFiltersService { + #jsonApiService = inject(JsonApiService); + #store = inject(Store); + + #getFilterParams(): Record { + return addFiltersParams( + this.#store.selectSignal(ResourceFiltersSelectors.getAllFilters)(), + ); + } + + #getParams(): Record { + const params: Record = {}; + const resourceTab = this.#store.selectSnapshot( + SearchSelectors.getResourceTab, + ); + const resourceTypes = getResourceTypes(resourceTab); + const searchText = this.#store.selectSnapshot( + SearchSelectors.getSearchText, + ); + const sort = this.#store.selectSnapshot(SearchSelectors.getSortBy); + + params['cardSearchFilter[resourceType]'] = resourceTypes; + params['cardSearchFilter[accessService]'] = 'https://staging4.osf.io/'; + params['cardSearchText[*,creator.name,isContainedBy.creator.name]'] = + searchText; + params['page[size]'] = '10'; + params['sort'] = sort; + return params; + } + + getCreators(valueSearchText: string): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'creator', + valueSearchText, + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse< + null, + ApiData<{ resourceMetadata: CreatorItem }, null, null>[] + > + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe( + map((response) => { + const included = (response?.included ?? []) as ApiData< + { resourceMetadata: CreatorItem }, + null, + null + >[]; + return included + .filter((item) => item.type === 'index-card') + .map((item) => MapCreators(item.attributes.resourceMetadata)); + }), + ); + } + + getDates(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'dateCreated', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapDateCreated(response?.included ?? []))); + } + + getFunders(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'funder', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapFunders(response?.included ?? []))); + } + + getSubjects(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'subject', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapSubject(response?.included ?? []))); + } + + getLicenses(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'rights', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapLicenses(response?.included ?? []))); + } + + getResourceTypes(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'resourceNature', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapResourceType(response?.included ?? []))); + } + + getInstitutions(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'affiliation', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapInstitutions(response?.included ?? []))); + } + + getProviders(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'publisher', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapProviders(response?.included ?? []))); + } + + getPartOtCollections(): Observable { + const dynamicParams = { + valueSearchPropertyPath: 'isPartOfCollection', + }; + + const fullParams = { + ...this.#getParams(), + ...this.#getFilterParams(), + ...dynamicParams, + }; + + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.shareDomainUrl}/index-value-search`, fullParams) + .pipe(map((response) => MapPartOfCollections(response?.included ?? []))); + } +} diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts index a4b8ab400..547df8280 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts @@ -1,46 +1,68 @@ -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; - export class SetCreator { static readonly type = '[Resource Filters] Set Creator'; - constructor(public payload: string) {} + constructor( + public name: string, + public id: string, + ) {} } export class SetDateCreated { static readonly type = '[Resource Filters] Set DateCreated'; - constructor(public payload: Date) {} + constructor(public date: string) {} } export class SetFunder { static readonly type = '[Resource Filters] Set Funder'; - constructor(public payload: string) {} + constructor( + public funder: string, + public id: string, + ) {} } export class SetSubject { static readonly type = '[Resource Filters] Set Subject'; - constructor(public payload: string) {} + constructor( + public subject: string, + public id: string, + ) {} } export class SetLicense { static readonly type = '[Resource Filters] Set License'; - constructor(public payload: string) {} + constructor( + public license: string, + public id: string, + ) {} } export class SetResourceType { static readonly type = '[Resource Filters] Set Resource Type'; - constructor(public payload: ResourceType) {} + constructor( + public resourceType: string, + public id: string, + ) {} } export class SetInstitution { static readonly type = '[Resource Filters] Set Institution'; - constructor(public payload: string) {} + constructor( + public institution: string, + public id: string, + ) {} } export class SetProvider { static readonly type = '[Resource Filters] Set Provider'; - constructor(public payload: string) {} + constructor( + public provider: string, + public id: string, + ) {} } export class SetPartOfCollection { static readonly type = '[Resource Filters] Set PartOfCollection'; - constructor(public payload: string) {} + constructor( + public partOfCollection: string, + public id: string, + ) {} } diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.model.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.model.ts index 7da4e749b..a8d2ce815 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.model.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.model.ts @@ -1,13 +1,17 @@ -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; - export interface ResourceFiltersStateModel { - creator: string; - dateCreated: Date; - funder: string; - subject: string; - license: string; - resourceType: ResourceType; - institution: string; - provider: string; - partOfCollection: string; + creator: ResourceFilterLabel; + dateCreated: ResourceFilterLabel; + funder: ResourceFilterLabel; + subject: ResourceFilterLabel; + license: ResourceFilterLabel; + resourceType: ResourceFilterLabel; + institution: ResourceFilterLabel; + provider: ResourceFilterLabel; + partOfCollection: ResourceFilterLabel; +} + +export interface ResourceFilterLabel { + filterName: string; + label?: string; + value?: string; } diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts index 64b0f654f..6d6ce979e 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts @@ -1,51 +1,74 @@ import { ResourceFiltersState } from '@shared/components/resources/resource-filters/store/resource-filters.state'; import { Selector } from '@ngxs/store'; -import { ResourceFiltersStateModel } from '@shared/components/resources/resource-filters/store/resource-filters.model'; -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; +import { + ResourceFilterLabel, + ResourceFiltersStateModel, +} from '@shared/components/resources/resource-filters/store/resource-filters.model'; export class ResourceFiltersSelectors { @Selector([ResourceFiltersState]) - static getCreator(state: ResourceFiltersStateModel): string { + static getAllFilters( + state: ResourceFiltersStateModel, + ): ResourceFiltersStateModel { + return { + creator: state.creator, + dateCreated: state.dateCreated, + funder: state.funder, + subject: state.subject, + license: state.license, + resourceType: state.resourceType, + institution: state.institution, + provider: state.provider, + partOfCollection: state.partOfCollection, + }; + } + + @Selector([ResourceFiltersState]) + static getCreator(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.creator; } @Selector([ResourceFiltersState]) - static getDateCreated(state: ResourceFiltersStateModel): Date { + static getDateCreated(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.dateCreated; } @Selector([ResourceFiltersState]) - static getFunder(state: ResourceFiltersStateModel): string { + static getFunder(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.funder; } @Selector([ResourceFiltersState]) - static getSubject(state: ResourceFiltersStateModel): string { + static getSubject(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.subject; } @Selector([ResourceFiltersState]) - static getLicense(state: ResourceFiltersStateModel): string { + static getLicense(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.license; } @Selector([ResourceFiltersState]) - static getResourceType(state: ResourceFiltersStateModel): ResourceType { + static getResourceType( + state: ResourceFiltersStateModel, + ): ResourceFilterLabel { return state.resourceType; } @Selector([ResourceFiltersState]) - static getInstitution(state: ResourceFiltersStateModel): string { + static getInstitution(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.institution; } @Selector([ResourceFiltersState]) - static getProvider(state: ResourceFiltersStateModel): string { + static getProvider(state: ResourceFiltersStateModel): ResourceFilterLabel { return state.provider; } @Selector([ResourceFiltersState]) - static getPartOfCollection(state: ResourceFiltersStateModel): string { + static getPartOfCollection( + state: ResourceFiltersStateModel, + ): ResourceFilterLabel { return state.partOfCollection; } } diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts index 02b5f095b..9c84ca3ad 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts @@ -1,5 +1,7 @@ import { ResourceFiltersStateModel } from '@shared/components/resources/resource-filters/store/resource-filters.model'; import { Action, State, StateContext } from '@ngxs/store'; + +import { Injectable } from '@angular/core'; import { SetCreator, SetDateCreated, @@ -11,26 +13,70 @@ import { SetResourceType, SetSubject, } from '@shared/components/resources/resource-filters/store/resource-filters.actions'; -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; +import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; +// Store for user selected filters values @State({ name: 'resourceFilters', defaults: { - creator: '', - dateCreated: new Date(), - funder: '', - subject: '', - license: '', - resourceType: ResourceType.Null, - institution: '', - provider: '', - partOfCollection: '', + creator: { + filterName: FilterLabels.creator, + label: undefined, + value: undefined, + }, + dateCreated: { + filterName: FilterLabels.dateCreated, + label: undefined, + value: undefined, + }, + funder: { + filterName: FilterLabels.funder, + label: undefined, + value: undefined, + }, + subject: { + filterName: FilterLabels.subject, + label: undefined, + value: undefined, + }, + license: { + filterName: FilterLabels.license, + label: undefined, + value: undefined, + }, + resourceType: { + filterName: FilterLabels.resourceType, + label: undefined, + value: undefined, + }, + institution: { + filterName: FilterLabels.institution, + label: undefined, + value: undefined, + }, + provider: { + filterName: FilterLabels.provider, + label: undefined, + value: undefined, + }, + partOfCollection: { + filterName: FilterLabels.partOfCollection, + label: undefined, + value: undefined, + }, }, }) +@Injectable() export class ResourceFiltersState { @Action(SetCreator) setCreator(ctx: StateContext, action: SetCreator) { - ctx.patchState({ creator: action.payload }); + ctx.patchState({ + creator: { + filterName: FilterLabels.creator, + label: action.name, + value: action.id, + }, + }); } @Action(SetDateCreated) @@ -38,22 +84,46 @@ export class ResourceFiltersState { ctx: StateContext, action: SetDateCreated, ) { - ctx.patchState({ dateCreated: action.payload }); + ctx.patchState({ + dateCreated: { + filterName: FilterLabels.dateCreated, + label: action.date, + value: action.date, + }, + }); } @Action(SetFunder) setFunder(ctx: StateContext, action: SetFunder) { - ctx.patchState({ funder: action.payload }); + ctx.patchState({ + funder: { + filterName: FilterLabels.funder, + label: action.funder, + value: action.id, + }, + }); } @Action(SetSubject) setSubject(ctx: StateContext, action: SetSubject) { - ctx.patchState({ subject: action.payload }); + ctx.patchState({ + subject: { + filterName: FilterLabels.subject, + label: action.subject, + value: action.id, + }, + }); } @Action(SetLicense) setLicense(ctx: StateContext, action: SetLicense) { - ctx.patchState({ license: action.payload }); + ctx.patchState({ + license: { + filterName: FilterLabels.license, + label: action.license, + value: action.id, + }, + }); } @Action(SetResourceType) @@ -61,7 +131,13 @@ export class ResourceFiltersState { ctx: StateContext, action: SetResourceType, ) { - ctx.patchState({ resourceType: action.payload }); + ctx.patchState({ + resourceType: { + filterName: FilterLabels.resourceType, + label: action.resourceType, + value: action.id, + }, + }); } @Action(SetInstitution) @@ -69,7 +145,13 @@ export class ResourceFiltersState { ctx: StateContext, action: SetInstitution, ) { - ctx.patchState({ institution: action.payload }); + ctx.patchState({ + institution: { + filterName: FilterLabels.institution, + label: action.institution, + value: action.id, + }, + }); } @Action(SetProvider) @@ -77,7 +159,13 @@ export class ResourceFiltersState { ctx: StateContext, action: SetProvider, ) { - ctx.patchState({ provider: action.payload }); + ctx.patchState({ + provider: { + filterName: FilterLabels.provider, + label: action.provider, + value: action.id, + }, + }); } @Action(SetPartOfCollection) @@ -85,6 +173,12 @@ export class ResourceFiltersState { ctx: StateContext, action: SetPartOfCollection, ) { - ctx.patchState({ partOfCollection: action.payload }); + ctx.patchState({ + partOfCollection: { + filterName: FilterLabels.partOfCollection, + label: action.partOfCollection, + value: action.id, + }, + }); } } diff --git a/src/app/shared/components/resources/resource-filters/utils/add-filters-params.helper.ts b/src/app/shared/components/resources/resource-filters/utils/add-filters-params.helper.ts new file mode 100644 index 000000000..e60cf5bf0 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/utils/add-filters-params.helper.ts @@ -0,0 +1,38 @@ +import { ResourceFiltersStateModel } from '@shared/components/resources/resource-filters/store'; + +export function addFiltersParams( + filters: ResourceFiltersStateModel, +): Record { + const params: Record = {}; + + if (filters.creator.value) { + params['cardSearchFilter[creator][]'] = filters.creator.value; + } + if (filters.dateCreated.value) { + params['cardSearchFilter[dateCreated][]'] = filters.dateCreated.value; + } + if (filters.subject.value) { + params['cardSearchFilter[subject][]'] = filters.subject.value; + } + if (filters.funder.value) { + params['cardSearchFilter[funder][]'] = filters.funder.value; + } + if (filters.license.value) { + params['cardSearchFilter[rights][]'] = filters.license.value; + } + if (filters.resourceType.value) { + params['cardSearchFilter[resourceNature][]'] = filters.resourceType.value; + } + if (filters.institution.value) { + params['cardSearchFilter[affiliation][]'] = filters.institution.value; + } + if (filters.provider.value) { + params['cardSearchFilter[publisher][]'] = filters.provider.value; + } + if (filters.partOfCollection.value) { + params['cardSearchFilter[isPartOfCollection][]'] = + filters.partOfCollection.value; + } + + return params; +} diff --git a/src/app/shared/components/resources/resources.component.html b/src/app/shared/components/resources/resources.component.html index 1b25c9098..667cbc7aa 100644 --- a/src/app/shared/components/resources/resources.component.html +++ b/src/app/shared/components/resources/resources.component.html @@ -1,29 +1,92 @@
-

10 000+ results

+ @if (searchCount() > 10000) { +

10 000+ results

+ } @else if (searchCount() > 0) { +

{{ searchCount() }} results

+ } @else { +

0 results

+ } +

Sort by:

- + [(ngModel)]="selectedSort" + >
- - +
+ @if (isAnyFilterSelected()) { + + } + +
+ +
- @for (item of items; track item.id) { - + @if (items.length > 0) { + @for (item of items; track item.id) { + + } + +
+ @if (first() && prev()) { + + + + } + + + + + + + + +
}
diff --git a/src/app/shared/components/resources/resources.component.scss b/src/app/shared/components/resources/resources.component.scss index 4cd392977..5a8586f3d 100644 --- a/src/app/shared/components/resources/resources.component.scss +++ b/src/app/shared/components/resources/resources.component.scss @@ -17,6 +17,13 @@ } } + .filters-container { + display: flex; + flex-direction: column; + width: 30%; + row-gap: 0.8rem; + } + .resources-container { width: 70%; @@ -26,5 +33,20 @@ flex-direction: column; row-gap: 0.85rem; } + + .switch-icon { + &:hover { + cursor: pointer; + } + } + + .icon-disabled { + opacity: 0.5; + cursor: none; + } + + .icon-active { + fill: var.$grey-1; + } } } diff --git a/src/app/shared/components/resources/resources.component.ts b/src/app/shared/components/resources/resources.component.ts index baeb7bfad..1dde54021 100644 --- a/src/app/shared/components/resources/resources.component.ts +++ b/src/app/shared/components/resources/resources.component.ts @@ -2,14 +2,14 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, input, + signal, + untracked, } from '@angular/core'; -import { TabOption } from '@shared/entities/tab-option.interface'; -import { Resource } from '@osf/features/search/models/resource.entity'; import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; -import { ResourceType } from '@osf/features/search/models/resource-type.enum'; import { DropdownModule } from 'primeng/dropdown'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ResourceFiltersComponent } from '@shared/components/resources/resource-filters/resource-filters.component'; @@ -18,9 +18,16 @@ import { AccordionModule } from 'primeng/accordion'; import { TableModule } from 'primeng/table'; import { DataViewModule } from 'primeng/dataview'; import { ResourceCardComponent } from '@shared/components/resources/resource-card/resource-card.component'; -import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store/resource-filters.selectors'; import { Store } from '@ngxs/store'; import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; +import { + GetResourcesByLink, + SearchSelectors, + SetSortBy, +} from '@osf/features/search/store'; +import { FilterChipsComponent } from '@shared/components/resources/filter-chips/filter-chips.component'; +import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; +import { Select } from 'primeng/select'; @Component({ selector: 'osf-resources', @@ -34,50 +41,76 @@ import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; TableModule, DataViewModule, ResourceCardComponent, + FilterChipsComponent, + Select, ], templateUrl: './resources.component.html', styleUrl: './resources.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResourcesComponent { + readonly #store = inject(Store); selectedTab = input.required(); - searchedResources = input.required(); - protected readonly filteredResources = computed(() => { - return this.searchedResources().filter((resources) => { - switch (this.selectedTab()) { - case ResourceTab.Projects: - return ( - resources.resourceType === ResourceType.Project || - resources.resourceType === ResourceType.ProjectComponent - ); - case ResourceTab.Registrations: - return resources.resourceType === ResourceType.Registration; - case ResourceTab.Preprints: - return resources.resourceType === ResourceType.Preprint; - case ResourceTab.Files: - return resources.resourceType === ResourceType.File; - case ResourceTab.Users: - return resources.resourceType === ResourceType.User; - default: - return true; - } - }); + searchCount = this.#store.selectSignal(SearchSelectors.getResourcesCount); + resources = this.#store.selectSignal(SearchSelectors.getResources); + sortBy = this.#store.selectSignal(SearchSelectors.getSortBy); + first = this.#store.selectSignal(SearchSelectors.getFirst); + next = this.#store.selectSignal(SearchSelectors.getNext); + prev = this.#store.selectSignal(SearchSelectors.getPrevious); + + protected filters = this.#store.selectSignal( + ResourceFiltersSelectors.getAllFilters, + ); + protected isAnyFilterSelected = computed(() => { + return ( + this.filters().creator.value || + this.filters().dateCreated.value || + this.filters().funder.value || + this.filters().subject.value || + this.filters().license.value || + this.filters().resourceType.value || + this.filters().institution.value || + this.filters().provider.value || + this.filters().partOfCollection.value + ); }); protected readonly isMobile = toSignal(inject(IS_XSMALL)); - defaultTabValue = 0; - protected selectedSortTab = this.defaultTabValue; - protected readonly sortTabOptions: TabOption[] = [ - { label: 'Relevance', value: 0 }, - { label: 'Date created (newest)', value: 1 }, - { label: 'Date created (oldest)', value: 2 }, - { label: 'Date modified (newest)', value: 3 }, - { label: 'Date modified (oldest)', value: 4 }, + protected selectedSort = signal(''); + + protected readonly sortTabOptions = [ + { label: 'Relevance', value: '-relevance' }, + { label: 'Date created (newest)', value: '-dateCreated' }, + { label: 'Date created (oldest)', value: 'dateCreated' }, + { label: 'Date modified (newest)', value: '-dateModified' }, + { label: 'Date modified (oldest)', value: 'dateModified' }, ]; - readonly #store = inject(Store); - protected readonly creator = this.#store.selectSignal( - ResourceFiltersSelectors.getCreator, - ); + constructor() { + // if new value for sorting in store, update value in dropdown + effect(() => { + const storeValue = this.sortBy(); + const currentInput = untracked(() => this.selectedSort()); + + if (storeValue && currentInput !== storeValue) { + this.selectedSort.set(storeValue); + } + }); + + // if the sorting was changed, set new value to store + effect(() => { + const chosenValue = this.selectedSort(); + const storeValue = untracked(() => this.sortBy()); + + if (chosenValue !== storeValue) { + this.#store.dispatch(new SetSortBy(chosenValue)); + } + }); + } + + // pagination + switchPage(link: string) { + this.#store.dispatch(new GetResourcesByLink(link)); + } } diff --git a/src/app/shared/entities/metadata-field.inteface.ts b/src/app/shared/entities/metadata-field.inteface.ts new file mode 100644 index 000000000..11e221696 --- /dev/null +++ b/src/app/shared/entities/metadata-field.inteface.ts @@ -0,0 +1,6 @@ +export interface MetadataField { + '@id': string; + identifier: { '@value': string }[]; + name: { '@value': string }[]; + resourceType: { '@id': string }[]; +} diff --git a/src/assets/icons/source/close.svg b/src/assets/icons/source/close.svg index ecb33baa7..ec6576438 100644 --- a/src/assets/icons/source/close.svg +++ b/src/assets/icons/source/close.svg @@ -1,4 +1,4 @@ - + diff --git a/src/assets/icons/source/code-colored.svg b/src/assets/icons/source/code-colored.svg new file mode 100644 index 000000000..7c93dd72a --- /dev/null +++ b/src/assets/icons/source/code-colored.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/source/data-colored.svg b/src/assets/icons/source/data-colored.svg new file mode 100644 index 000000000..2fcc5ca02 --- /dev/null +++ b/src/assets/icons/source/data-colored.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/source/materials-colored.svg b/src/assets/icons/source/materials-colored.svg new file mode 100644 index 000000000..b54edbd1a --- /dev/null +++ b/src/assets/icons/source/materials-colored.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/source/papers-colored.svg b/src/assets/icons/source/papers-colored.svg new file mode 100644 index 000000000..ba354e388 --- /dev/null +++ b/src/assets/icons/source/papers-colored.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/source/supplements-colored.svg b/src/assets/icons/source/supplements-colored.svg new file mode 100644 index 000000000..94bb47334 --- /dev/null +++ b/src/assets/icons/source/supplements-colored.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/system/chevron-left.svg b/src/assets/icons/system/chevron-left.svg new file mode 100644 index 000000000..533060724 --- /dev/null +++ b/src/assets/icons/system/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/system/chevron-right.svg b/src/assets/icons/system/chevron-right.svg new file mode 100644 index 000000000..39f6be076 --- /dev/null +++ b/src/assets/icons/system/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/system/first.svg b/src/assets/icons/system/first.svg new file mode 100644 index 000000000..b3a1e45a7 --- /dev/null +++ b/src/assets/icons/system/first.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/styles/_variables.scss b/src/assets/styles/_variables.scss index 354ead6ab..7c96b0206 100644 --- a/src/assets/styles/_variables.scss +++ b/src/assets/styles/_variables.scss @@ -55,6 +55,9 @@ $gradient-1: var(--gradient-1); $gradient-2: var(--gradient-2); $gradient-3: var(--gradient-3); +// Opacity Colors +$white-60: var(--white-60); + :root { // Fonts --openSans: "Open Sans", sans-serif; @@ -105,6 +108,9 @@ $gradient-3: var(--gradient-3); --blue-1-bg: #ebf4f7; --blue-2-bg: #ebf2f8; + // Opacity Colors + --white-60: rgba(256, 256, 256, 0.6); + // Gradients --gradient-1: linear-gradient( diff --git a/src/assets/styles/overrides/accordion.scss b/src/assets/styles/overrides/accordion.scss index e5ffab605..85a3d055d 100644 --- a/src/assets/styles/overrides/accordion.scss +++ b/src/assets/styles/overrides/accordion.scss @@ -59,3 +59,20 @@ } } } + +.filters { + .p-accordion { + .p-accordioncontent-content { + padding-bottom: 1.5rem; + .content-body { + display: flex; + flex-direction: column; + row-gap: 1.5rem; + + p { + font-weight: 300; + } + } + } + } +} diff --git a/src/assets/styles/overrides/chip.scss b/src/assets/styles/overrides/chip.scss new file mode 100644 index 000000000..78484ba9f --- /dev/null +++ b/src/assets/styles/overrides/chip.scss @@ -0,0 +1,29 @@ +@use "assets/styles/variables" as var; + +.p-chip { + height: fit-content; + width: fit-content; + background: var.$bg-blue-3; + border-radius: 4px; + padding: 4px 12px 4px 12px; + + &:hover { + cursor: pointer; + background: var.$bg-blue-2; + color: var.$pr-blue-3; + i { + color: var.$pr-blue-1; + } + } + + .p-chip-label { + font-size: 1rem; + font-weight: 400; + line-height: 1.7rem; + } + + .p-chip-remove-icon { + display: flex; + margin-left: 12px; + } +} diff --git a/src/assets/styles/overrides/dropdown.scss b/src/assets/styles/overrides/dropdown.scss index 90e836aaf..bbd7a0baf 100644 --- a/src/assets/styles/overrides/dropdown.scss +++ b/src/assets/styles/overrides/dropdown.scss @@ -20,22 +20,3 @@ } } } - -.no-border-dropdown { - .p-dropdown { - border: none; - font-size: 1rem; - box-shadow: none; - height: 2rem; - - .p-select-label { - padding: 0; - font-weight: 700; - } - - .p-select-overlay { - right: 0; - left: auto; - } - } -} diff --git a/src/assets/styles/overrides/paginator.scss b/src/assets/styles/overrides/paginator.scss index 728bfd5d1..bb42c0ad5 100644 --- a/src/assets/styles/overrides/paginator.scss +++ b/src/assets/styles/overrides/paginator.scss @@ -79,3 +79,50 @@ .p-paginator-prev:not(.p-disabled):hover { background: white; } + +.p-select-overlay { + background: white; + border: 1px solid var.$grey-2; + color: var.$dark-blue-1; +} + +.p-select-option { + &.p-select-option-selected { + background-color: var.$bg-blue-2; + color: var.$dark-blue-1; + + &:hover { + background: var.$bg-blue-3; + color: var.$dark-blue-1; + } + } + + &:not(.p-select-option-selected):not(.p-disabled).p-focus { + background: var.$bg-blue-3; + color: var.$dark-blue-1; + } +} + +p-select { + background: white; + border: 1px solid var.$grey-2; + + span { + color: var.$dark-blue-1; + } +} + +.resources-container { + .p-paginator { + .p-paginator-pages, + .p-paginator-last, + .p-paginator-prev { + display: none; + } + .p-paginator-first, + .p-paginator-next, + .p-paginator-prev { + opacity: 1 !important; + } + } +} diff --git a/src/assets/styles/overrides/select.scss b/src/assets/styles/overrides/select.scss index 9852fb310..76016df54 100644 --- a/src/assets/styles/overrides/select.scss +++ b/src/assets/styles/overrides/select.scss @@ -8,6 +8,10 @@ outline: none; font-size: 16px; color: var.$dark-blue-1; + + .p-placeholder { + color: var.$grey-1; + } } .p-select-label { @@ -57,4 +61,45 @@ white-space: nowrap; } } + + .p-select { + border: none; + font-size: 1rem; + box-shadow: none; + height: 2rem; + + .p-select-label { + padding: 0; + font-weight: 700; + } + + .p-select-overlay { + position: absolute; + right: 0; + left: auto; + } + } +} + +.filter { + .p-select { + .p-select-label { + font-size: 1.2rem; + font-weight: 400; + color: var.$dark-blue-1; + padding-right: 0.5rem; + } + + .p-select-clear-icon { + right: 1rem; + } + } +} + +.dropdown-filter { + .p-select { + .p-select-clear-icon { + right: 2.2rem; + } + } } diff --git a/src/assets/styles/overrides/tabs.scss b/src/assets/styles/overrides/tabs.scss index 14a10d441..155348968 100644 --- a/src/assets/styles/overrides/tabs.scss +++ b/src/assets/styles/overrides/tabs.scss @@ -1,5 +1,15 @@ @use "assets/styles/variables" as var; +.p-tab { + padding: 1rem 1.7rem 1rem 1.7rem; + + &:not(.p-tab-active):hover { + background: var.$white-60; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + } +} + .p-tabpanels { padding: 1.71rem; } @@ -24,3 +34,9 @@ display: none; } } + +.resources-tab-panel { + .p-tabpanels { + padding: 0 !important; + } +} diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 417cb8182..0f9874834 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -24,6 +24,7 @@ @use "./overrides/select"; @use "./overrides/accordion"; @use "./overrides/paginator"; +@use "./overrides/chip"; @use "./overrides/tag"; @layer base, primeng, reset; @@ -136,4 +137,9 @@ border-color: var.$grey-1 transparent transparent; } } + + i, + svg { + outline: none; + } } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 2d4be37a9..4fbee9975 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,4 +1,5 @@ export const environment = { production: false, apiUrl: 'https://api.staging4.osf.io/v2', + shareDomainUrl: 'https://staging-share.osf.io/trove', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 2d4be37a9..4fbee9975 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: false, apiUrl: 'https://api.staging4.osf.io/v2', + shareDomainUrl: 'https://staging-share.osf.io/trove', };