From 4d5652278e3515efd8548d17baac50940234da29 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Fri, 2 Sep 2022 12:14:12 +0200 Subject: [PATCH 1/8] feat(es): handle userSavedCount in model & mappers --- .../search/src/lib/utils/mapper/elasticsearch.field.mapper.ts | 4 ++++ .../search/src/lib/utils/mapper/elasticsearch.mapper.spec.ts | 1 + libs/util/shared/src/lib/elasticsearch/constant.ts | 1 + .../src/lib/elasticsearch/elasticsearch.service.spec.ts | 1 + .../shared/src/lib/elasticsearch/fixtures/full-response.ts | 1 + .../shared/src/lib/elasticsearch/fixtures/search-responses.ts | 4 ++++ libs/util/shared/src/lib/models/search.model.ts | 1 + 7 files changed, 13 insertions(+) diff --git a/libs/feature/search/src/lib/utils/mapper/elasticsearch.field.mapper.ts b/libs/feature/search/src/lib/utils/mapper/elasticsearch.field.mapper.ts index 07f4aaa1ba..ab4f4827ef 100644 --- a/libs/feature/search/src/lib/utils/mapper/elasticsearch.field.mapper.ts +++ b/libs/feature/search/src/lib/utils/mapper/elasticsearch.field.mapper.ts @@ -142,6 +142,10 @@ export class ElasticsearchFieldMapper { lineage: selectTranslatedField(source, 'lineageObject'), }), mainLanguage: (output) => output, + userSavedCount: (output, source) => ({ + ...output, + favoriteCount: parseInt(selectField(source, 'userSavedCount')), + }), } private genericField = (output) => output diff --git a/libs/feature/search/src/lib/utils/mapper/elasticsearch.mapper.spec.ts b/libs/feature/search/src/lib/utils/mapper/elasticsearch.mapper.spec.ts index 7f7d8d1867..3bb5e2656e 100644 --- a/libs/feature/search/src/lib/utils/mapper/elasticsearch.mapper.spec.ts +++ b/libs/feature/search/src/lib/utils/mapper/elasticsearch.mapper.spec.ts @@ -52,6 +52,7 @@ describe('ElasticsearchMapper', () => { 'Urban Waste Water Treatment Directive, Sensitive areas - rivers reported under UWWTD data call 2015, Nov. 2017', uuid: '5b35f06e-8c6b-4907-b8f4-39541d170360', catalogUuid: '6731be1e-6533-44e0-9b8a-580b45e36e80', + favoriteCount: 4, }, ]) }) diff --git a/libs/util/shared/src/lib/elasticsearch/constant.ts b/libs/util/shared/src/lib/elasticsearch/constant.ts index b8dcd35c5f..75ea36abd4 100644 --- a/libs/util/shared/src/lib/elasticsearch/constant.ts +++ b/libs/util/shared/src/lib/elasticsearch/constant.ts @@ -16,6 +16,7 @@ export const ES_SOURCE_SUMMARY = [ 'codelist_status_text', 'linkProtocol', 'contact.organisation', + 'userSavedCount', ] export const ES_SOURCE_BRIEF = [ diff --git a/libs/util/shared/src/lib/elasticsearch/elasticsearch.service.spec.ts b/libs/util/shared/src/lib/elasticsearch/elasticsearch.service.spec.ts index dc07cf3710..348c5becb8 100644 --- a/libs/util/shared/src/lib/elasticsearch/elasticsearch.service.spec.ts +++ b/libs/util/shared/src/lib/elasticsearch/elasticsearch.service.spec.ts @@ -252,6 +252,7 @@ describe('ElasticsearchService', () => { 'codelist_status_text', 'linkProtocol', 'contact.organisation', + 'userSavedCount', ], query: { bool: { diff --git a/libs/util/shared/src/lib/elasticsearch/fixtures/full-response.ts b/libs/util/shared/src/lib/elasticsearch/fixtures/full-response.ts index 42e8aaae95..246f4d74ec 100644 --- a/libs/util/shared/src/lib/elasticsearch/fixtures/full-response.ts +++ b/libs/util/shared/src/lib/elasticsearch/fixtures/full-response.ts @@ -1514,6 +1514,7 @@ export const ES_FIXTURE_FULL_RESPONSE = { rating: '0', isHarvested: 'false', sourceCatalogue: '81e8a591-7815-4d2f-a7da-5673192e74c9', + userSavedCount: '12', }, edit: false, owner: false, diff --git a/libs/util/shared/src/lib/elasticsearch/fixtures/search-responses.ts b/libs/util/shared/src/lib/elasticsearch/fixtures/search-responses.ts index 96ad950236..607a421e01 100644 --- a/libs/util/shared/src/lib/elasticsearch/fixtures/search-responses.ts +++ b/libs/util/shared/src/lib/elasticsearch/fixtures/search-responses.ts @@ -199,6 +199,7 @@ export const hitsOnly: unknown = { ], resourceType: ['dataset'], sourceCatalogue: '6731be1e-6533-44e0-9b8a-580b45e36e80', + userSavedCount: '4', }, edit: false, owner: false, @@ -293,6 +294,7 @@ export const summaryHits = { 'WWW:LINK-1.0-http--link', 'OGC:GML', ], + userSavedCount: '5', }, sort: [1660176316000], edit: false, @@ -363,6 +365,7 @@ export const summaryHits = { 'OGC:GML', 'WWW:LINK-1.0-http--link', ], + userSavedCount: '0', }, sort: [1660089739000], edit: false, @@ -420,6 +423,7 @@ export const summaryHits = { 'text/xml', 'image/png', ], + userSavedCount: '0', }, sort: [1660089729000], edit: false, diff --git a/libs/util/shared/src/lib/models/search.model.ts b/libs/util/shared/src/lib/models/search.model.ts index 094f26a338..625f427dce 100644 --- a/libs/util/shared/src/lib/models/search.model.ts +++ b/libs/util/shared/src/lib/models/search.model.ts @@ -51,6 +51,7 @@ export interface MetadataRecord { contact?: MetadataContact catalogUuid?: string usageConstraints?: string + favoriteCount?: number } export interface MetadataLinkValid { From 6af88678ed8a28072c5cd036ee785c9528f20ccc Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Tue, 30 Aug 2022 21:37:45 +0200 Subject: [PATCH 2/8] feature(search): add a service for handling favorite records --- .../lib/favorites/favorites.service.spec.ts | 214 ++++++++++++++++++ .../src/lib/favorites/favorites.service.ts | 107 +++++++++ 2 files changed, 321 insertions(+) create mode 100644 libs/feature/search/src/lib/favorites/favorites.service.spec.ts create mode 100644 libs/feature/search/src/lib/favorites/favorites.service.ts diff --git a/libs/feature/search/src/lib/favorites/favorites.service.spec.ts b/libs/feature/search/src/lib/favorites/favorites.service.spec.ts new file mode 100644 index 0000000000..08f99550e8 --- /dev/null +++ b/libs/feature/search/src/lib/favorites/favorites.service.spec.ts @@ -0,0 +1,214 @@ +import { FavoritesService } from './favorites.service' +import { AuthService } from '@geonetwork-ui/feature/auth' +import { + MeResponseApiModel, + UserselectionsApiService, +} from '@geonetwork-ui/data-access/gn4' +import { of, throwError } from 'rxjs' +import { readFirst } from '@nrwl/angular/testing' +import { delay } from 'rxjs/operators' +import { fakeAsync, tick } from '@angular/core/testing' + +class AuthServiceMock { + authReady = jest.fn(() => + of({ + id: '1234', + name: 'fakeuser', + } as MeResponseApiModel) + ) +} + +class UserSelectionsServiceMock { + getSelectionRecords = jest.fn(() => of(['abcd', 'efgh', 'ijkl'])) + addToUserSelection = jest.fn(() => of('')) + deleteFromUserSelection = jest.fn(() => of('')) +} + +describe('FavoritesService', () => { + let service: FavoritesService + let userSelectionsService: UserselectionsApiService + let authService: AuthService + + beforeEach(() => { + userSelectionsService = new UserSelectionsServiceMock() as any + authService = new AuthServiceMock() as any + service = new FavoritesService(userSelectionsService, authService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) + + describe('myFavorites$', () => { + describe('when not authenticated', () => { + beforeEach(() => { + authService.authReady = () => of(null) + service = new FavoritesService(userSelectionsService, authService) + }) + it('returns an empty array', async () => { + const uuids = await readFirst(service.myFavoritesUuid$) + expect(uuids).toEqual([]) + }) + }) + describe('when an error happens', () => { + beforeEach(() => { + userSelectionsService.getSelectionRecords = jest.fn(() => + throwError(new Error('blargz')) + ) + }) + it('throws an error', async () => { + expect.assertions(2) + try { + await readFirst(service.myFavoritesUuid$) + } catch (e: any) { + expect(e.message).toContain('fetching favorite records') + expect(e.message).toContain('blargz') + } + }) + }) + it('emits a list of saved record uuids', async () => { + const uuids = await readFirst(service.myFavoritesUuid$) + expect(uuids).toEqual(['abcd', 'efgh', 'ijkl']) + }) + describe('when subscribing multiple times', () => { + beforeEach(fakeAsync(() => { + ;(userSelectionsService as any).getSelectionRecords = jest.fn(() => + of(['aa']).pipe( + delay(100) // add a delay to make sure that there are no concurrent requests + ) + ) + service.myFavoritesUuid$.subscribe() + service.myFavoritesUuid$.subscribe() + tick(200) + service.myFavoritesUuid$.subscribe() + })) + it('only calls the API once', () => { + expect(userSelectionsService.getSelectionRecords).toHaveBeenCalledTimes( + 1 + ) + }) + }) + }) + + describe('addToFavorites', () => { + let favorites + describe('when not authenticated', () => { + beforeEach(() => { + authService.authReady = () => of(null) + service = new FavoritesService(userSelectionsService, authService) + }) + it('throws an error', async () => { + expect.assertions(1) + try { + await service.addToFavorites(['aaa']).toPromise() + } catch (e: any) { + expect(e.message).toContain('not authenticated') + } + }) + }) + describe('when an error happens', () => { + beforeEach(() => { + favorites = null + service.myFavoritesUuid$.subscribe((value) => (favorites = value)) + userSelectionsService.addToUserSelection = jest.fn(() => + throwError(new Error('blargz')) + ) + }) + it('throws an error', async () => { + expect.assertions(2) + try { + await service.addToFavorites(['aaa']).toPromise() + } catch (e: any) { + expect(e.message).toContain('adding records') + expect(e.message).toContain('blargz') + } + }) + it('does not add the record to favorites', async () => { + expect.assertions(1) + try { + await service.addToFavorites(['zzz']).toPromise() + } catch (e) { + // ignore + } + expect(favorites).toEqual(['abcd', 'efgh', 'ijkl']) + }) + }) + describe('nominal case', () => { + beforeEach(async () => { + favorites = null + service.myFavoritesUuid$.subscribe((value) => (favorites = value)) + await service.addToFavorites(['uvw', 'xyz']).toPromise() + }) + it('calls the corresponding API', () => { + expect(userSelectionsService.addToUserSelection).toHaveBeenCalledWith( + 0, + 1234, + ['uvw', 'xyz'] + ) + }) + it('emits new records in myFavorites$', () => { + expect(favorites).toEqual(['abcd', 'efgh', 'ijkl', 'uvw', 'xyz']) + }) + }) + }) + + describe('removeFromFavorites', () => { + let favorites + describe('when not authenticated', () => { + beforeEach(() => { + authService.authReady = () => of(null) + service = new FavoritesService(userSelectionsService, authService) + }) + it('throws an error', async () => { + expect.assertions(1) + try { + await service.removeFromFavorites(['aaa']).toPromise() + } catch (e: any) { + expect(e.message).toContain('not authenticated') + } + }) + }) + describe('when an error happens', () => { + beforeEach(() => { + favorites = null + service.myFavoritesUuid$.subscribe((value) => (favorites = value)) + userSelectionsService.deleteFromUserSelection = jest.fn(() => + throwError(new Error('blargz')) + ) + }) + it('throws an error', async () => { + expect.assertions(2) + try { + await service.removeFromFavorites(['aaa']).toPromise() + } catch (e: any) { + expect(e.message).toContain('removing records') + expect(e.message).toContain('blargz') + } + }) + it('does not remove the record from favorites', async () => { + expect.assertions(1) + try { + await service.removeFromFavorites(['abcd']).toPromise() + } catch (e) { + // ignore + } + expect(favorites).toEqual(['abcd', 'efgh', 'ijkl']) + }) + }) + describe('nominal case', () => { + beforeEach(async () => { + favorites = null + service.myFavoritesUuid$.subscribe((value) => (favorites = value)) + await service.removeFromFavorites(['abcd', 'ijkl']).toPromise() + }) + it('calls the corresponding API', () => { + expect( + userSelectionsService.deleteFromUserSelection + ).toHaveBeenCalledWith(0, 1234, ['abcd', 'ijkl']) + }) + it('emits records without the ones removed in myFavorites$', () => { + expect(favorites).toEqual(['efgh']) + }) + }) + }) +}) diff --git a/libs/feature/search/src/lib/favorites/favorites.service.ts b/libs/feature/search/src/lib/favorites/favorites.service.ts new file mode 100644 index 0000000000..edc1518ecb --- /dev/null +++ b/libs/feature/search/src/lib/favorites/favorites.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@angular/core' +import { combineLatest, merge, Observable, of, Subject, throwError } from 'rxjs' +import { UserselectionsApiService } from '@geonetwork-ui/data-access/gn4' +import { AuthService } from '@geonetwork-ui/feature/auth' +import { + catchError, + map, + shareReplay, + switchMap, + take, + tap, +} from 'rxjs/operators' + +const SELECTION_ID = 0 // hardcoded to always point on the first selection + +@Injectable({ + providedIn: 'root', +}) +export class FavoritesService { + private myId$ = this.authService + .authReady() + .pipe(map((userInfo) => (userInfo ? parseInt(userInfo.id) : null))) + + private myFavoritesUuidFromApi$ = this.myId$.pipe( + switchMap((userId) => + userId !== null + ? this.userSelectionsService.getSelectionRecords(SELECTION_ID, userId) + : of([] as string[]) + ), + catchError((e) => + throwError( + new Error( + `An error occurred while fetching favorite records: ${e.message}` + ) + ) + ) + ) + + private modifiedFavorites$ = new Subject() + + myFavoritesUuid$ = merge( + this.myFavoritesUuidFromApi$, + this.modifiedFavorites$ + ).pipe(shareReplay(1)) + + constructor( + private userSelectionsService: UserselectionsApiService, + private authService: AuthService + ) {} + + addToFavorites(uuids: string[]): Observable { + return combineLatest([this.myId$, this.myFavoritesUuid$]).pipe( + take(1), + tap(([userId]) => { + if (userId === null) throw new Error('not authenticated') + }), + switchMap(([userId, favorites]) => + this.userSelectionsService + .addToUserSelection(SELECTION_ID, userId, uuids) + .pipe(tap(() => this.emitAddedFavorites(favorites, uuids))) + ), + map(() => undefined), + catchError((e) => + throwError( + new Error( + `An error occurred while adding records to favorites: ${e.message}` + ) + ) + ) + ) + } + + removeFromFavorites(uuids: string[]): Observable { + return combineLatest([this.myId$, this.myFavoritesUuid$]).pipe( + take(1), + tap(([userId]) => { + if (userId === null) throw new Error('not authenticated') + }), + switchMap(([userId, favorites]) => + this.userSelectionsService + .deleteFromUserSelection(SELECTION_ID, userId, uuids) + .pipe(tap(() => this.emitRemovedFavorites(favorites, uuids))) + ), + map(() => undefined), + catchError((e) => + throwError( + new Error( + `An error occurred while removing records from favorites: ${e.message}` + ) + ) + ) + ) + } + + private emitAddedFavorites(favorites: string[], addedUuids: string[]) { + this.modifiedFavorites$.next([ + ...favorites.filter((uuid) => addedUuids.indexOf(uuid) === -1), + ...addedUuids, + ]) + } + + private emitRemovedFavorites(favorites: string[], removedUuids: string[]) { + this.modifiedFavorites$.next( + favorites.filter((uuid) => removedUuids.indexOf(uuid) === -1) + ) + } +} From bd4787a93b39a979b8ae580e66209f082b7a8968 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Thu, 1 Sep 2022 14:14:23 +0200 Subject: [PATCH 3/8] feat(inputs): add star toggle input --- libs/ui/inputs/src/index.ts | 1 + .../lib/star-toggle/star-toggle.component.css | 28 +++++++++++++++ .../star-toggle/star-toggle.component.html | 29 +++++++++++++++ .../star-toggle/star-toggle.component.spec.ts | 22 ++++++++++++ .../lib/star-toggle/star-toggle.component.ts | 35 +++++++++++++++++++ .../lib/star-toggle/star-toggle.stories.ts | 24 +++++++++++++ libs/ui/inputs/src/lib/ui-inputs.module.ts | 3 ++ 7 files changed, 142 insertions(+) create mode 100644 libs/ui/inputs/src/lib/star-toggle/star-toggle.component.css create mode 100644 libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html create mode 100644 libs/ui/inputs/src/lib/star-toggle/star-toggle.component.spec.ts create mode 100644 libs/ui/inputs/src/lib/star-toggle/star-toggle.component.ts create mode 100644 libs/ui/inputs/src/lib/star-toggle/star-toggle.stories.ts diff --git a/libs/ui/inputs/src/index.ts b/libs/ui/inputs/src/index.ts index 9445c42455..4d03ab9a99 100644 --- a/libs/ui/inputs/src/index.ts +++ b/libs/ui/inputs/src/index.ts @@ -4,4 +4,5 @@ export * from './lib/chips-input/chips-input.component' export * from './lib/datepicker/datepicker.component' export * from './lib/text-area/text-area.component' export * from './lib/autocomplete/autocomplete.component' +export * from './lib/star-toggle/star-toggle.component' export * from './lib/ui-inputs.module' diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.css b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.css new file mode 100644 index 0000000000..2fb8e1ba87 --- /dev/null +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.css @@ -0,0 +1,28 @@ +mat-icon { + width: 1em; + height: 1em; + font-size: 1.5em; +} + +.star-toggle-overlay { + stroke: #f43f5e; + stroke-width: 3.5px; + stroke-linecap: round; + position: absolute; + top: 50%; + left: 50%; + width: 2.5em; + height: 2.5em; + transform: translate(-50%, -50%); + pointer-events: none; + stroke-dasharray: 5 20; + stroke-dashoffset: -15; + animation: overlay-dash 0.8s cubic-bezier(0.16, 0.66, 0.44, 0.96) forwards; + animation-play-state: paused; +} + +@keyframes overlay-dash { + to { + stroke-dashoffset: 7; + } +} diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html new file mode 100644 index 0000000000..5658b80d2e --- /dev/null +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html @@ -0,0 +1,29 @@ +
+ + + + + + + + + + +
diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.spec.ts b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.spec.ts new file mode 100644 index 0000000000..fa6fd306f1 --- /dev/null +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { StarToggleComponent } from './star-toggle.component' + +describe('StarToggleComponent', () => { + let component: StarToggleComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [StarToggleComponent], + }).compileComponents() + + fixture = TestBed.createComponent(StarToggleComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.ts b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.ts new file mode 100644 index 0000000000..c18e076211 --- /dev/null +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core' + +@Component({ + selector: 'gn-ui-star-toggle', + templateUrl: './star-toggle.component.html', + styleUrls: ['./star-toggle.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StarToggleComponent { + @Input() toggled!: boolean + @Input() disabled = false + @Output() newValue = new EventEmitter() + @ViewChild('starOverlay') overlay: ElementRef + + toggle(event: Event) { + if (!this.disabled) { + this.toggled = !this.toggled + if (this.toggled) { + const anim = this.overlay.nativeElement.getAnimations()[0] + anim.cancel() + anim.play() + } + this.newValue.emit(this.toggled) + } + event.stopPropagation() + } +} diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.stories.ts b/libs/ui/inputs/src/lib/star-toggle/star-toggle.stories.ts new file mode 100644 index 0000000000..6310e73cd8 --- /dev/null +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.stories.ts @@ -0,0 +1,24 @@ +import { moduleMetadata, Story, Meta } from '@storybook/angular' +import { StarToggleComponent } from './star-toggle.component' +import { action } from '@storybook/addon-actions' + +export default { + title: 'Inputs/StarToggle', + component: StarToggleComponent, + decorators: [ + moduleMetadata({ + imports: [], + }), + ], +} as Meta + +const Template: Story = (args: StarToggleComponent) => ({ + component: StarToggleComponent, + props: { ...args, newValue: action('newValue') }, +}) + +export const Primary = Template.bind({}) +Primary.args = { + toggled: false, + disabled: false, +} diff --git a/libs/ui/inputs/src/lib/ui-inputs.module.ts b/libs/ui/inputs/src/lib/ui-inputs.module.ts index 9bd0b7108f..5fe8d97ca8 100644 --- a/libs/ui/inputs/src/lib/ui-inputs.module.ts +++ b/libs/ui/inputs/src/lib/ui-inputs.module.ts @@ -19,6 +19,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete' import { MatIconModule } from '@angular/material/icon' import { NavigationButtonComponent } from './navigation-button/navigation-button.component' import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' +import { StarToggleComponent } from './star-toggle/star-toggle.component' @NgModule({ declarations: [ @@ -31,6 +32,7 @@ import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' ChipsInputComponent, DatepickerComponent, NavigationButtonComponent, + StarToggleComponent, ], imports: [ BrowserModule, @@ -56,6 +58,7 @@ import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' ChipsInputComponent, DatepickerComponent, NavigationButtonComponent, + StarToggleComponent, ], }) export class UiInputsModule {} From 36f6d46d56a31d49c163c277840b135d569b177c Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Fri, 2 Sep 2022 11:41:09 +0200 Subject: [PATCH 4/8] feat(search): add favorite star component --- .../favorite-star/favorite-star.component.css | 0 .../favorite-star.component.html | 13 ++ .../favorite-star.component.spec.ts | 197 ++++++++++++++++++ .../favorite-star/favorite-star.component.ts | 58 ++++++ .../search/src/lib/feature-search.module.ts | 5 + 5 files changed, 273 insertions(+) create mode 100644 libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.css create mode 100644 libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.html create mode 100644 libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.spec.ts create mode 100644 libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.ts diff --git a/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.css b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.html b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.html new file mode 100644 index 0000000000..fbe1055cf6 --- /dev/null +++ b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.html @@ -0,0 +1,13 @@ +
+ {{ + favoriteCount + }} + +
diff --git a/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.spec.ts b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.spec.ts new file mode 100644 index 0000000000..392c068d58 --- /dev/null +++ b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.spec.ts @@ -0,0 +1,197 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FavoriteStarComponent } from './favorite-star.component' +import { BehaviorSubject, of, throwError } from 'rxjs' +import { AuthService } from '@geonetwork-ui/feature/auth' +import { FavoritesService } from '../favorites.service' +import { StarToggleComponent } from '@geonetwork-ui/ui/inputs' +import { RECORDS_SUMMARY_FIXTURE } from '@geonetwork-ui/util/shared' +import { By } from '@angular/platform-browser' +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' + +class AuthServiceMock { + authReady = jest.fn(() => this._authSubject) + _authSubject = new BehaviorSubject({ + id: '1234', + name: 'fakeuser', + }) +} + +class FavoritesServiceMock { + myFavoritesUuid$ = new BehaviorSubject([]) + removeFromFavorites = jest.fn(() => of(true)) + addToFavorites = jest.fn(() => of(true)) +} + +describe('FavoriteStarComponent', () => { + let component: FavoriteStarComponent + let fixture: ComponentFixture + let authService: AuthService + let favoritesService: FavoritesService + let favoriteCountEl: HTMLElement + let starToggle: StarToggleComponent + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [FavoriteStarComponent, StarToggleComponent], + imports: [TranslateModule.forRoot()], + providers: [ + { + provide: AuthService, + useClass: AuthServiceMock, + }, + { + provide: FavoritesService, + useClass: FavoritesServiceMock, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(FavoriteStarComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents() + + authService = TestBed.inject(AuthService) + favoritesService = TestBed.inject(FavoritesService) + fixture = TestBed.createComponent(FavoriteStarComponent) + component = fixture.componentInstance + component.record = { ...RECORDS_SUMMARY_FIXTURE[0], favoriteCount: 42 } + fixture.detectChanges() + starToggle = fixture.debugElement.query( + By.directive(StarToggleComponent) + ).componentInstance + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('when a record has a favorite count', () => { + beforeEach(() => { + favoriteCountEl = fixture.debugElement.query( + By.css('.favorite-count') + ).nativeElement + }) + it('shows the amount of favorites on the record', () => { + expect(favoriteCountEl).toBeTruthy() + expect(favoriteCountEl.textContent).toEqual( + component.record.favoriteCount.toString() + ) + }) + }) + describe('when a record does not have a favorite count', () => { + beforeEach(() => { + component.record = { ...RECORDS_SUMMARY_FIXTURE[0] } + delete component.record.favoriteCount + fixture.detectChanges() + }) + it('does not show the amount of favorites on the record', () => { + expect(fixture.debugElement.query(By.css('.favorite-count'))).toBeFalsy() + }) + }) + describe('when not authenticated', () => { + beforeEach(() => { + ;(authService as any)._authSubject.next(null) + fixture.detectChanges() + }) + it('star toggle is disabled', () => { + expect(starToggle.disabled).toBe(true) + }) + }) + describe('when authenticated', () => { + it('star toggle is enabled', () => { + expect(starToggle.disabled).toBe(false) + }) + describe('on toggle state change', () => { + beforeEach(() => { + favoriteCountEl = fixture.debugElement.query( + By.css('.favorite-count') + ).nativeElement + }) + describe('if record is not part of favorite', () => { + beforeEach(() => { + ;(favoritesService as any).myFavoritesUuid$.next([ + 'aaa', + 'bbb', + 'ccc', + ]) + starToggle.newValue.emit(true) + fixture.detectChanges() + }) + it('adds record to favorites', () => { + expect(favoritesService.addToFavorites).toHaveBeenCalledWith([ + component.record.uuid, + ]) + expect(favoritesService.removeFromFavorites).not.toHaveBeenCalled() + }) + it('increase record favorite count by one', () => { + expect(favoriteCountEl.textContent).toEqual( + (component.record.favoriteCount + 1).toString() + ) + }) + }) + describe('if record is part of favorite', () => { + beforeEach(() => { + ;(favoritesService as any).myFavoritesUuid$.next([ + 'aaa', + 'bbb', + component.record.uuid, + ]) + starToggle.newValue.emit(false) + fixture.detectChanges() + }) + it('removes record from favorites', () => { + expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith([ + component.record.uuid, + ]) + expect(favoritesService.addToFavorites).not.toHaveBeenCalled() + }) + it('decrease record favorite count by one', () => { + expect(favoriteCountEl.textContent).toEqual( + (component.record.favoriteCount - 1).toString() + ) + }) + }) + describe('two subsequent changes', () => { + beforeEach(() => { + ;(favoritesService as any).myFavoritesUuid$.next([ + 'aaa', + 'bbb', + component.record.uuid, + ]) + starToggle.newValue.emit(false) + starToggle.newValue.emit(true) + fixture.detectChanges() + }) + it('removes and adds record to favorites', () => { + expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith([ + component.record.uuid, + ]) + expect(favoritesService.addToFavorites).toHaveBeenCalledWith([ + component.record.uuid, + ]) + }) + it('record favorite count stays the same', () => { + expect(favoriteCountEl.textContent).toEqual( + component.record.favoriteCount.toString() + ) + }) + }) + describe('if favorite modification fails', () => { + beforeEach(() => { + favoritesService.addToFavorites = () => throwError('blargz') + starToggle.newValue.emit(true) + fixture.detectChanges() + }) + it('does not change record favorite count', () => { + expect(favoriteCountEl.textContent).toEqual( + component.record.favoriteCount.toString() + ) + }) + }) + }) + }) +}) diff --git a/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.ts b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.ts new file mode 100644 index 0000000000..d640c79828 --- /dev/null +++ b/libs/feature/search/src/lib/favorites/favorite-star/favorite-star.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { FavoritesService } from '../favorites.service' +import { MetadataRecord } from '@geonetwork-ui/util/shared' +import { map } from 'rxjs/operators' +import { AuthService } from '@geonetwork-ui/feature/auth' + +@Component({ + selector: 'gn-ui-favorite-star', + templateUrl: './favorite-star.component.html', + styleUrls: ['./favorite-star.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FavoriteStarComponent { + @Input() set record(value) { + this.record_ = value + this.favoriteCount = + 'favoriteCount' in this.record_ ? this.record_.favoriteCount : null + } + get record() { + return this.record_ + } + isFavorite$ = this.favoritesService.myFavoritesUuid$.pipe( + map((favorites) => favorites.indexOf(this.record.uuid) > -1) + ) + isAnonymous$ = this.authService + .authReady() + .pipe(map((user) => !user || !('id' in user))) + record_: MetadataRecord + favoriteCount: number | null + loading = false + + get hasFavoriteCount() { + return this.favoriteCount !== null + } + + constructor( + private favoritesService: FavoritesService, + private authService: AuthService + ) {} + + toggleFavorite(isFavorite) { + this.loading = true + ;(isFavorite + ? this.favoritesService.addToFavorites([this.record.uuid]) + : this.favoritesService.removeFromFavorites([this.record.uuid]) + ).subscribe({ + complete: () => { + if (this.hasFavoriteCount) { + this.favoriteCount += isFavorite ? 1 : -1 + } + this.loading = false + }, + error: () => { + this.loading = false + }, + }) + } +} diff --git a/libs/feature/search/src/lib/feature-search.module.ts b/libs/feature/search/src/lib/feature-search.module.ts index 262f6b6066..e7cb521c51 100644 --- a/libs/feature/search/src/lib/feature-search.module.ts +++ b/libs/feature/search/src/lib/feature-search.module.ts @@ -19,6 +19,8 @@ import { SearchStateContainerDirective } from './state/container/search-state.co import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { NgModule } from '@angular/core' import { UiElementsModule } from '@geonetwork-ui/ui/elements' +import { FavoriteStarComponent } from './favorites/favorite-star/favorite-star.component' +import { MatIconModule } from '@angular/material/icon' @NgModule({ declarations: [ @@ -29,6 +31,7 @@ import { UiElementsModule } from '@geonetwork-ui/ui/elements' ResultsListContainerComponent, ResultsHitsContainerComponent, SearchStateContainerDirective, + FavoriteStarComponent, ], imports: [ CommonModule, @@ -45,6 +48,7 @@ import { UiElementsModule } from '@geonetwork-ui/ui/elements' ApiModule, FacetsModule, InfiniteScrollModule, + MatIconModule, ], exports: [ SortByComponent, @@ -55,6 +59,7 @@ import { UiElementsModule } from '@geonetwork-ui/ui/elements' ResultsHitsContainerComponent, FacetsModule, SearchStateContainerDirective, + FavoriteStarComponent, ], }) export class FeatureSearchModule {} From 021255af1c7e9ab227e8f2dcaf0d2aa352ce5a53 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Fri, 2 Sep 2022 12:13:42 +0200 Subject: [PATCH 5/8] feat(datahub): add favorite star on search results --- .../record-preview-datahub.component.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/datahub/src/app/home/search/record-preview-datahub/record-preview-datahub.component.html b/apps/datahub/src/app/home/search/record-preview-datahub/record-preview-datahub.component.html index f3a117d9e6..c90134f3da 100644 --- a/apps/datahub/src/app/home/search/record-preview-datahub/record-preview-datahub.component.html +++ b/apps/datahub/src/app/home/search/record-preview-datahub/record-preview-datahub.component.html @@ -10,7 +10,7 @@
map
+
From 5416ed0ced921fe1b9d811135c1bfd8eeebb5234 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Fri, 2 Sep 2022 13:14:08 +0200 Subject: [PATCH 6/8] i18n: add translations --- translations/de.json | 1 + translations/en.json | 1 + translations/es.json | 1 + translations/fr.json | 1 + translations/it.json | 1 + translations/nl.json | 1 + translations/pt.json | 1 + 7 files changed, 7 insertions(+) diff --git a/translations/de.json b/translations/de.json index 263a94f245..bbe9881998 100644 --- a/translations/de.json +++ b/translations/de.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "", "facets.block.title.tag.default": "", "facets.block.title.th_regions_tree.default": "", + "favorite.not.authenticated": "", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", diff --git a/translations/en.json b/translations/en.json index ff74581a07..5e06eac143 100644 --- a/translations/en.json +++ b/translations/en.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "Keywords", "facets.block.title.tag.default": "Tag", "facets.block.title.th_regions_tree.default": "Regions", + "favorite.not.authenticated": "You must be authenticated to add favorites", "map.add.layer": "Add a layer", "map.add.layer.catalog": "From the catalog", "map.add.layer.file": "From a file", diff --git a/translations/es.json b/translations/es.json index 263a94f245..bbe9881998 100644 --- a/translations/es.json +++ b/translations/es.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "", "facets.block.title.tag.default": "", "facets.block.title.th_regions_tree.default": "", + "favorite.not.authenticated": "", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", diff --git a/translations/fr.json b/translations/fr.json index dee27bed45..0a08dcd0f3 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "Mots clés", "facets.block.title.tag.default": "Tag", "facets.block.title.th_regions_tree.default": "Régions", + "favorite.not.authenticated": "Vous devez vous authentifier pour ajouter des favoris", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", diff --git a/translations/it.json b/translations/it.json index a7f3b0c717..39d698b3fb 100644 --- a/translations/it.json +++ b/translations/it.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "", "facets.block.title.tag.default": "", "facets.block.title.th_regions_tree.default": "", + "favorite.not.authenticated": "", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", diff --git a/translations/nl.json b/translations/nl.json index 263a94f245..bbe9881998 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "", "facets.block.title.tag.default": "", "facets.block.title.th_regions_tree.default": "", + "favorite.not.authenticated": "", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", diff --git a/translations/pt.json b/translations/pt.json index 263a94f245..bbe9881998 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -104,6 +104,7 @@ "facets.block.title.tag": "", "facets.block.title.tag.default": "", "facets.block.title.th_regions_tree.default": "", + "favorite.not.authenticated": "", "map.add.layer": "", "map.add.layer.catalog": "", "map.add.layer.file": "", From a43e2529779f66924b71f1a2a25436683a63c025 Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Mon, 5 Sep 2022 10:15:24 +0200 Subject: [PATCH 7/8] feat(input): adjust colors of star toggle --- libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html index 5658b80d2e..c057c7fe92 100644 --- a/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html +++ b/libs/ui/inputs/src/lib/star-toggle/star-toggle.component.html @@ -4,7 +4,7 @@ (click)="toggle($event)" [ngClass]="{ 'text-secondary': toggled, - 'text-gray-500': !toggled, + 'text-primary opacity-25': !toggled, 'transition hover:scale-125 will-change-transform': !disabled, 'cursor-default': disabled }" From ac16ad144d39b2d761fe351bd16a168e72c957ba Mon Sep 17 00:00:00 2001 From: Olivier Guyot Date: Mon, 5 Sep 2022 10:26:08 +0200 Subject: [PATCH 8/8] feat(search): clarify the observable logic in favorite service --- .../src/lib/favorites/favorites.service.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/libs/feature/search/src/lib/favorites/favorites.service.ts b/libs/feature/search/src/lib/favorites/favorites.service.ts index edc1518ecb..90f4a6cec2 100644 --- a/libs/feature/search/src/lib/favorites/favorites.service.ts +++ b/libs/feature/search/src/lib/favorites/favorites.service.ts @@ -9,6 +9,7 @@ import { switchMap, take, tap, + withLatestFrom, } from 'rxjs/operators' const SELECTION_ID = 0 // hardcoded to always point on the first selection @@ -17,15 +18,17 @@ const SELECTION_ID = 0 // hardcoded to always point on the first selection providedIn: 'root', }) export class FavoritesService { - private myId$ = this.authService + private myUserId$ = this.authService .authReady() .pipe(map((userInfo) => (userInfo ? parseInt(userInfo.id) : null))) - private myFavoritesUuidFromApi$ = this.myId$.pipe( - switchMap((userId) => - userId !== null - ? this.userSelectionsService.getSelectionRecords(SELECTION_ID, userId) - : of([] as string[]) + // this observable loads the current list of favorites from the API + private myFavoritesUuidFromApi$ = this.myUserId$.pipe( + switchMap( + (userId) => + userId !== null + ? this.userSelectionsService.getSelectionRecords(SELECTION_ID, userId) + : of([] as string[]) // emit an empty array if the user is not authentified ), catchError((e) => throwError( @@ -38,10 +41,15 @@ export class FavoritesService { private modifiedFavorites$ = new Subject() + // favorites are loaded once from the API (from myFavoritesUuidFromApi$); + // subsequent emissions are caused by modifications of the favorite list + // on the client side (coming from modifiedFavorites$) myFavoritesUuid$ = merge( this.myFavoritesUuidFromApi$, this.modifiedFavorites$ - ).pipe(shareReplay(1)) + ).pipe( + shareReplay(1) // new subscriptions should not trigger a new API request! + ) constructor( private userSelectionsService: UserselectionsApiService, @@ -49,12 +57,13 @@ export class FavoritesService { ) {} addToFavorites(uuids: string[]): Observable { - return combineLatest([this.myId$, this.myFavoritesUuid$]).pipe( + return this.myFavoritesUuid$.pipe( take(1), - tap(([userId]) => { + withLatestFrom(this.myUserId$), + tap(([, userId]) => { if (userId === null) throw new Error('not authenticated') }), - switchMap(([userId, favorites]) => + switchMap(([favorites, userId]) => this.userSelectionsService .addToUserSelection(SELECTION_ID, userId, uuids) .pipe(tap(() => this.emitAddedFavorites(favorites, uuids))) @@ -71,12 +80,13 @@ export class FavoritesService { } removeFromFavorites(uuids: string[]): Observable { - return combineLatest([this.myId$, this.myFavoritesUuid$]).pipe( + return this.myFavoritesUuid$.pipe( take(1), - tap(([userId]) => { + withLatestFrom(this.myUserId$), + tap(([, userId]) => { if (userId === null) throw new Error('not authenticated') }), - switchMap(([userId, favorites]) => + switchMap(([favorites, userId]) => this.userSelectionsService .deleteFromUserSelection(SELECTION_ID, userId, uuids) .pipe(tap(() => this.emitRemovedFavorites(favorites, uuids)))