diff --git a/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.spec.ts b/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.spec.ts index d9f7ae3482..8ce6ca590b 100644 --- a/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.spec.ts +++ b/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.spec.ts @@ -15,7 +15,7 @@ class RecordsServiceMock { } class OrganisationsServiceMock { - countOrganisations = () => of(456) + organisationsCount$ = of(456) } describe('KeyFiguresComponent', () => { diff --git a/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.ts b/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.ts index 8b10714c65..38e8b70651 100644 --- a/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.ts +++ b/apps/datahub/src/app/home/news-page/key-figures/key-figures.component.ts @@ -18,7 +18,7 @@ import { }) export class KeyFiguresComponent { recordsCount$ = this.catalogRecords.recordsCount$.pipe(startWith('-')) - orgsCount$ = this.catalogOrgs.countOrganisations().pipe(startWith('-')) + orgsCount$ = this.catalogOrgs.organisationsCount$.pipe(startWith('-')) ROUTE_SEARCH = `/${ROUTER_ROUTE_HOME}/${ROUTER_ROUTE_SEARCH}` ROUTE_ORGANISATIONS = `/${ROUTER_ROUTE_HOME}/${ROUTER_ROUTE_ORGANISATIONS}` diff --git a/libs/feature/catalog/src/lib/group/group.service.spec.ts b/libs/feature/catalog/src/lib/group/group.service.spec.ts new file mode 100644 index 0000000000..d0ffdc673c --- /dev/null +++ b/libs/feature/catalog/src/lib/group/group.service.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing' +import { GroupsApiService } from '@geonetwork-ui/data-access/gn4' +import { of } from 'rxjs' + +import { GroupService } from './group.service' + +const groupsApiMock = [ + { + name: 'agence', + label: { eng: 'AGENCE-DE-TEST' }, + description: 'une agence', + logo: 'logo-ag.png', + }, + { + name: 'association', + label: { eng: 'Association National du testing' }, + description: 'une association', + logo: 'logo-asso.png', + }, +] + +const groupsApiServiceMock = { + getGroups: jest.fn(() => of(groupsApiMock)), +} + +describe('GroupsService', () => { + let service: GroupService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: GroupsApiService, + useValue: groupsApiServiceMock, + }, + ], + }) + service = TestBed.inject(GroupService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/libs/feature/catalog/src/lib/group/group.service.ts b/libs/feature/catalog/src/lib/group/group.service.ts new file mode 100644 index 0000000000..52235422a0 --- /dev/null +++ b/libs/feature/catalog/src/lib/group/group.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core' +import { GroupApiModel, GroupsApiService } from '@geonetwork-ui/data-access/gn4' +import { Observable } from 'rxjs' +import { shareReplay } from 'rxjs/operators' + +@Injectable({ + providedIn: 'root', +}) +export class GroupService { + groups$: Observable = this.groupsApiService + .getGroups() + .pipe(shareReplay()) + + constructor(private groupsApiService: GroupsApiService) {} +} diff --git a/libs/feature/catalog/src/lib/organisations/organisations.component.html b/libs/feature/catalog/src/lib/organisations/organisations.component.html index 2e05e295cb..670daa1950 100644 --- a/libs/feature/catalog/src/lib/organisations/organisations.component.html +++ b/libs/feature/catalog/src/lib/organisations/organisations.component.html @@ -4,11 +4,16 @@
- + [showContent]="organisation.description !== null" + > + +
of(ORGANISATIONS_FIXTURE)) + hydratedOrganisations$ = of(ORGANISATIONS_FIXTURE) } class SearchServiceMock { @@ -73,6 +74,7 @@ describe('OrganisationsComponent', () => { OrganisationsSortMockComponent, OrganisationPreviewMockComponent, PaginationMockComponent, + ContentGhostComponent, ], providers: [ { @@ -111,9 +113,6 @@ describe('OrganisationsComponent', () => { beforeEach(() => { paginationComponentDE = de.query(By.directive(PaginationMockComponent)) }) - it('should call getOrganisationsWithGroups', () => { - expect(organisationsService.getOrganisationsWithGroups).toHaveBeenCalled() - }) describe('pass organisations to ui preview components', () => { beforeEach(() => { orgPreviewComponents = de diff --git a/libs/feature/catalog/src/lib/organisations/organisations.component.ts b/libs/feature/catalog/src/lib/organisations/organisations.component.ts index ee27bd8d5b..294d9d9d93 100644 --- a/libs/feature/catalog/src/lib/organisations/organisations.component.ts +++ b/libs/feature/catalog/src/lib/organisations/organisations.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { SearchService } from '@geonetwork-ui/feature/search' import { Organisation } from '@geonetwork-ui/util/shared' import { BehaviorSubject, combineLatest, Observable } from 'rxjs' -import { map, tap } from 'rxjs/operators' +import { map, startWith, tap } from 'rxjs/operators' import { OrganisationsService } from './organisations.service' @Component({ @@ -23,7 +23,9 @@ export class OrganisationsComponent { sortBy$ = new BehaviorSubject('name-asc') organisationsSorted$: Observable = combineLatest([ - this.organisationsService.getOrganisationsWithGroups(), + this.organisationsService.hydratedOrganisations$.pipe( + startWith(Array(this.itemsOnPage).fill({})) + ), this.sortBy$, ]).pipe( map(([organisations, sortBy]) => diff --git a/libs/feature/catalog/src/lib/organisations/organisations.service.spec.ts b/libs/feature/catalog/src/lib/organisations/organisations.service.spec.ts index aea1e6da77..d993c0f7b6 100644 --- a/libs/feature/catalog/src/lib/organisations/organisations.service.spec.ts +++ b/libs/feature/catalog/src/lib/organisations/organisations.service.spec.ts @@ -1,22 +1,66 @@ import { TestBed } from '@angular/core/testing' -import { GroupsApiService } from '@geonetwork-ui/data-access/gn4' -import { AggregationsService } from '@geonetwork-ui/feature/search' +import { SearchApiService } from '@geonetwork-ui/data-access/gn4' import { of } from 'rxjs' +import { take } from 'rxjs/operators' +import { GroupService } from '../group/group.service' import { OrganisationsService } from './organisations.service' const organisationsAggregationMock = { - buckets: [ - { key: 'Agence de test', doc_count: 5 }, - { key: 'Association pour le testing', doc_count: 3 }, - ], + aggregations: { + contact: { + org: { + buckets: [ + { + key: 'Agence de test', + doc_count: 5, + mail: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'test@agence.com', + doc_count: 3, + }, + { + key: 'test2@agence.com', + doc_count: 1, + }, + ], + }, + }, + { + key: 'Association pour le testing', + doc_count: 1, + mail: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'testing@assoc.net', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + }, } -const aggregationsServiceMock = { - getFullSearchTermAggregation: jest.fn(() => of(organisationsAggregationMock)), +const searchApiServiceMock = { + search: jest.fn(() => of(organisationsAggregationMock)), } const groupsApiMock = [ + { + name: 'agence de test', + label: { eng: 'AGENCE-DE-TEST' }, + description: 'une agence', + email: 'test@test.net', + logo: 'logo-ag.png', + }, { name: 'agence', label: { eng: 'AGENCE-DE-TEST' }, @@ -28,11 +72,12 @@ const groupsApiMock = [ label: { eng: 'Association National du testing' }, description: 'une association', logo: 'logo-asso.png', + email: 'testing@assoc.net', }, ] -const groupsApiServiceMock = { - getGroups: jest.fn(() => of(groupsApiMock)), +const groupServiceMock = { + groups$: of(groupsApiMock), } describe('OrganisationsService', () => { @@ -42,12 +87,12 @@ describe('OrganisationsService', () => { TestBed.configureTestingModule({ providers: [ { - provide: AggregationsService, - useValue: aggregationsServiceMock, + provide: SearchApiService, + useValue: searchApiServiceMock, }, { - provide: GroupsApiService, - useValue: groupsApiServiceMock, + provide: GroupService, + useValue: groupServiceMock, }, ], }) @@ -57,45 +102,105 @@ describe('OrganisationsService', () => { it('should be created', () => { expect(service).toBeTruthy() }) - describe('#getOrganisationsWithGroups', () => { + describe('hydratedOrganisations$', () => { let organisations - beforeEach(() => { - service - .getOrganisationsWithGroups() - .subscribe((orgs) => (organisations = orgs)) + describe('initially', () => { + beforeEach(() => { + service.hydratedOrganisations$ + .pipe(take(1)) + .subscribe((orgs) => (organisations = orgs)) + }) + it('get rough organisations', () => { + expect(organisations).toEqual([ + { + description: null, + emails: ['test@agence.com', 'test2@agence.com'], + logoUrl: null, + name: 'Agence de test', + recordCount: 5, + }, + { + description: null, + emails: ['testing@assoc.net'], + logoUrl: null, + name: 'Association pour le testing', + recordCount: 1, + }, + ]) + }) }) - it('should get organisations, enriching first one with description, logoUrl from groups', () => { - expect(organisations).toEqual([ - { - name: 'Agence de test', - description: 'une agence', - logoUrl: '/geonetwork/images/harvesting/logo-ag.png', - recordCount: 5, - }, - { - name: 'Association pour le testing', - description: undefined, - logoUrl: undefined, - recordCount: 3, - }, - ]) + describe('when groups tick', () => { + beforeEach(() => { + organisations = null + service.hydratedOrganisations$ + .pipe(take(2)) + .subscribe((orgs) => (organisations = orgs)) + }) + it('get organisations hydrated from groups via name or email mapping', () => { + expect(organisations).toEqual([ + { + description: 'une agence', + email: 'test@test.net', + emails: ['test@agence.com', 'test2@agence.com'], + logoUrl: '/geonetwork/images/harvesting/logo-ag.png', + name: 'Agence de test', + recordCount: 5, + }, + { + description: 'une association', + email: 'testing@assoc.net', + emails: ['testing@assoc.net'], + logoUrl: '/geonetwork/images/harvesting/logo-asso.png', + name: 'Association pour le testing', + recordCount: 1, + }, + ]) + }) }) }) - describe('#normalizeName', () => { + describe('#normalizeString', () => { it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { - expect(service.normalizeName('ATMO Haut de France')).toEqual( - service.normalizeName('ATMO Haut-de-France') + expect(service.normalizeString('ATMO Haut de France')).toEqual( + service.normalizeString('ATMO Haut-de-France') ) }) it('should match "ATMO Haut de France" and "ATMOHautdeFrance"', () => { - expect(service.normalizeName('ATMO Haut de France')).toEqual( - service.normalizeName('ATMOHautdeFrance') + expect(service.normalizeString('ATMO Haut de France')).toEqual( + service.normalizeString('ATMOHautdeFrance') ) }) it('should NOT match "ATMO Haut de France" and "ATMO HDF"', () => { - expect(service.normalizeName('ATMO Haut de France')).not.toEqual( - service.normalizeName('ATMO HDF') + expect(service.normalizeString('ATMO Haut de France')).not.toEqual( + service.normalizeString('ATMO HDF') ) }) }) + describe('#compareNormalizedString', () => { + it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { + expect( + service.equalsNormalizedStrings( + 'ATMO Haut de France', + 'ATMO Haut-de-France' + ) + ).toBeTruthy() + }) + it('should NOT match "ATMO Haut de France" and "ATMO Haut-de-France" (not replacing special chars)', () => { + expect( + service.equalsNormalizedStrings( + 'ATMO Haut de France', + 'ATMO Haut-de-France', + false + ) + ).toBeFalsy() + }) + it('should match email adresses (not replacing special chars)', () => { + expect( + service.equalsNormalizedStrings( + 'Some.user@C2C.com', + 'some.user@c2c.com', + false + ) + ).toBeTruthy() + }) + }) }) diff --git a/libs/feature/catalog/src/lib/organisations/organisations.service.ts b/libs/feature/catalog/src/lib/organisations/organisations.service.ts index 85f2160deb..b8fa40fd14 100644 --- a/libs/feature/catalog/src/lib/organisations/organisations.service.ts +++ b/libs/feature/catalog/src/lib/organisations/organisations.service.ts @@ -1,67 +1,151 @@ import { Injectable } from '@angular/core' -import { GroupApiModel, GroupsApiService } from '@geonetwork-ui/data-access/gn4' +import { GroupApiModel, SearchApiService } from '@geonetwork-ui/data-access/gn4' import { ElasticsearchService, Organisation } from '@geonetwork-ui/util/shared' -import { AggregationsService } from '@geonetwork-ui/feature/search' import { combineLatest, Observable } from 'rxjs' -import { map, shareReplay } from 'rxjs/operators' +import { filter, map, shareReplay, startWith, tap } from 'rxjs/operators' +import { GroupService } from '../group/group.service' const IMAGE_URL = '/geonetwork/images/harvesting/' -interface OrganisationApiModel { +type ESBucket = { key: string doc_count: number } +interface OrganisationAggsBucket extends ESBucket { + mail: { + buckets: ESBucket[] + } +} @Injectable({ providedIn: 'root', }) export class OrganisationsService { - groups$: Observable = this.groupsApiService.getGroups() - organisations$: Observable = this.aggregationsService - .getFullSearchTermAggregation('OrgForResource') - .pipe( + organisationsAggs$: Observable = + this.fetchOrgForResourceAggs().pipe( map((response) => response.buckets), shareReplay() ) + organisations$: Observable = this.organisationsAggs$.pipe( + map((buckets) => + buckets.map((bucket) => ({ + name: bucket.key, + emails: bucket.mail.buckets + .map((bucket) => bucket.key) + .filter((mail) => !!mail), + recordCount: bucket.doc_count, + description: null, + logoUrl: null, + })) + ) + ) + organisationsCount$ = this.organisationsAggs$.pipe( + map((organisations) => organisations.length) + ) + hydratedOrganisations$ = combineLatest([ + this.organisations$, + this.groupService.groups$.pipe(startWith(null)), + ]).pipe( + map(([organisations, groups]) => { + return !groups ? organisations : this.mapWithGroups(organisations, groups) + }) + ) constructor( private esService: ElasticsearchService, - private groupsApiService: GroupsApiService, - private aggregationsService: AggregationsService + private searchApiService: SearchApiService, + private groupService: GroupService ) {} - countOrganisations(): Observable { - return this.organisations$.pipe( - map((organisations) => organisations.length) + equalsNormalizedStrings( + str1: string, + str2: string, + replaceSpecialChars = true + ): boolean { + if (!str1 || !str2) return false + return ( + this.normalizeString(str1, replaceSpecialChars) === + this.normalizeString(str2, replaceSpecialChars) ) } - getOrganisationsWithGroups(): Observable { - return combineLatest([this.organisations$, this.groups$]).pipe( - map(([organisations, groups]) => - organisations.map((organisation) => { - const group = groups.find( - (group) => - (group.label.eng - ? this.normalizeName(group.label.eng) - : this.normalizeName(group.name)) === - this.normalizeName(organisation.key) - ) - return { - name: organisation.key, - description: group?.description, - logoUrl: group?.logo ? `${IMAGE_URL}${group.logo}` : undefined, - recordCount: organisation.doc_count, - } as Organisation - }) + normalizeString(str: string, replaceSpecialChars = true): string { + function normalize(str: string) { + return str + .normalize('NFD') // decompose graphemes to remove accents from letters + .replace(/[\u0300-\u036f]/g, '') // remove accent characters + .toLowerCase() + } + if (replaceSpecialChars) { + return normalize(str).replace(/[^a-z0-9]/g, '') // replace all except letters & numbers + } else { + return normalize(str) + } + } + + private fetchOrgForResourceAggs() { + return this.searchApiService + .search( + 'bucket', + JSON.stringify( + this.esService.getSearchRequestBody({ + contact: { + nested: { + path: 'contactForResource', + }, + aggs: { + org: { + terms: { + field: 'contactForResource.organisation', + exclude: '', + size: 1000, + order: { _key: 'asc' }, + }, + aggs: { + mail: { + terms: { + size: 1000, + exclude: '', + field: 'contactForResource.email.keyword', + }, + }, + }, + }, + }, + }, + }) + ) + ) + .pipe( + filter((response) => response.aggregations.contact.org), + map((response) => response.aggregations.contact.org) ) - ) } - normalizeName(name: string): string { - return name - .normalize('NFD') // decompose graphemes to remove accents from letters - .replace(/[\u0300-\u036f]/g, '') // remove accent characters - .toLowerCase() - .replace(/[^a-z0-9]/g, '') // replace all except letters & numbers + private mapWithGroups( + organisations: Organisation[], + groups: GroupApiModel[] + ) { + return organisations.map((organisation) => { + const group = + groups.find((group) => + this.equalsNormalizedStrings( + group.label.eng ? group.label.eng : group.name, + organisation.name + ) + ) ?? + groups + .filter((group) => !!group.email) + .find((group) => + organisation.emails + .map((mail) => this.normalizeString(mail, false)) + .includes(this.normalizeString(group.email, false)) + ) + return { + ...organisation, + email: group?.email || undefined, + description: group?.description || undefined, + logoUrl: group?.logo ? `${IMAGE_URL}${group.logo}` : undefined, + } as Organisation + }) } } diff --git a/libs/feature/catalog/src/lib/users/users.service.spec.ts b/libs/feature/catalog/src/lib/users/users.service.spec.ts new file mode 100644 index 0000000000..8925619f8a --- /dev/null +++ b/libs/feature/catalog/src/lib/users/users.service.spec.ts @@ -0,0 +1,31 @@ +import { TestBed } from '@angular/core/testing' +import { UsersApiService } from '@geonetwork-ui/data-access/gn4' +import { USERS_FIXTURE } from '@geonetwork-ui/util/shared/fixtures' +import { of } from 'rxjs' + +import { UsersService } from './users.service' + +class UsersApiServiceMock { + getUsers = jest.fn(() => of(USERS_FIXTURE())) +} + +describe('UsersService', () => { + let service: UsersService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: UsersApiService, + useClass: UsersApiServiceMock, + }, + ], + }) + TestBed.inject(UsersApiService) + service = TestBed.inject(UsersService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/libs/feature/catalog/src/lib/users/users.service.ts b/libs/feature/catalog/src/lib/users/users.service.ts new file mode 100644 index 0000000000..77f218647e --- /dev/null +++ b/libs/feature/catalog/src/lib/users/users.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core' +import { UsersApiService } from '@geonetwork-ui/data-access/gn4' +import { shareReplay } from 'rxjs/operators' + +@Injectable({ + providedIn: 'root', +}) +export class UsersService { + users$ = this.usersService.getUsers().pipe(shareReplay()) + + constructor(private usersService: UsersApiService) {} +} diff --git a/libs/ui/catalog/src/lib/organisation-preview/organisation-preview.component.html b/libs/ui/catalog/src/lib/organisation-preview/organisation-preview.component.html index 37aeb1f354..e9e78ecfff 100644 --- a/libs/ui/catalog/src/lib/organisation-preview/organisation-preview.component.html +++ b/libs/ui/catalog/src/lib/organisation-preview/organisation-preview.component.html @@ -1,20 +1,17 @@ -
+
{{ organisation.name }} diff --git a/libs/ui/elements/src/index.ts b/libs/ui/elements/src/index.ts index 5003185719..289d360ce2 100644 --- a/libs/ui/elements/src/index.ts +++ b/libs/ui/elements/src/index.ts @@ -4,3 +4,4 @@ export * from './lib/metadata-contact/metadata-contact.component' export * from './lib/metadata-catalog/metadata-catalog.component' export * from './lib/search-results-error/search-results-error.component' export * from './lib/thumbnail/thumbnail.component' +export * from './lib/content-ghost/content-ghost.component' diff --git a/libs/ui/search/src/lib/record-preview-feed/record-preview-feed.component.html b/libs/ui/search/src/lib/record-preview-feed/record-preview-feed.component.html index 19d53d1b0f..6b1ade37bf 100644 --- a/libs/ui/search/src/lib/record-preview-feed/record-preview-feed.component.html +++ b/libs/ui/search/src/lib/record-preview-feed/record-preview-feed.component.html @@ -58,7 +58,7 @@ [ngTemplateOutletContext]="{ $implicit: record }" >
-

+

{{ record.title }}

{{ record.abstract }}

diff --git a/libs/ui/search/src/lib/record-preview-row/record-preview-row.component.html b/libs/ui/search/src/lib/record-preview-row/record-preview-row.component.html index 7f7e526755..77c8379fbc 100644 --- a/libs/ui/search/src/lib/record-preview-row/record-preview-row.component.html +++ b/libs/ui/search/src/lib/record-preview-row/record-preview-row.component.html @@ -1,4 +1,7 @@ -
+
{{ record.title }}
diff --git a/libs/util/shared/src/lib/fixtures/user.fixtures.ts b/libs/util/shared/src/lib/fixtures/user.fixtures.ts index 170ee1ba69..56534f5ce6 100644 --- a/libs/util/shared/src/lib/fixtures/user.fixtures.ts +++ b/libs/util/shared/src/lib/fixtures/user.fixtures.ts @@ -10,3 +10,27 @@ export const USER_FIXTURE = (): UserModel => ({ organisation: 'RĂ©gion Hauts-de-France', admin: true, }) + +export const USERS_FIXTURE = (): UserModel[] => [ + USER_FIXTURE(), + { + id: '1', + profile: 'Editor', + username: 'neo', + name: 'Tomas', + surname: 'Anderson', + email: 't.anderson@matrix.com', + organisation: 'The matrix', + admin: true, + }, + { + id: '2', + profile: 'Editor', + username: 'trinity', + name: 'Tyfany', + surname: 'Trinity', + email: 't.trinity@matrix.com', + organisation: 'The matrix', + admin: true, + }, +] diff --git a/libs/util/shared/src/lib/models/search.model.ts b/libs/util/shared/src/lib/models/search.model.ts index a7f3c76181..0ffb128edb 100644 --- a/libs/util/shared/src/lib/models/search.model.ts +++ b/libs/util/shared/src/lib/models/search.model.ts @@ -18,6 +18,8 @@ export interface Organisation { description?: string logoUrl?: string recordCount?: number + email?: string + emails?: string[] } export interface MetadataContact {