From b829335ba5fdbcfbbdd684e1127ef4c557126dd3 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 5 Apr 2023 17:49:29 +0200 Subject: [PATCH 1/9] 100414: Missing search_result statistics fix --- .../search-page/search-tracker.component.ts | 143 ++++++++++++++---- .../statistics/angulartics/dspace-provider.ts | 3 +- src/app/statistics/statistics.service.ts | 7 +- 3 files changed, 120 insertions(+), 33 deletions(-) diff --git a/src/app/search-page/search-tracker.component.ts b/src/app/search-page/search-tracker.component.ts index e7f59a2f23..eb7ac6fcc3 100644 --- a/src/app/search-page/search-tracker.component.ts +++ b/src/app/search-page/search-tracker.component.ts @@ -1,6 +1,6 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Angulartics2 } from 'angulartics2'; -import { map, switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { SearchComponent } from './search.component'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { HostWindowService } from '../shared/host-window.service'; @@ -10,10 +10,17 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu import { SearchService } from '../core/shared/search/search.service'; import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { SearchObjects } from '../shared/search/search-objects.model'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { RemoteData } from '../core/data/remote-data'; import { DSpaceObject } from '../core/shared/dspace-object.model'; import { getFirstSucceededRemoteData } from '../core/shared/operators'; +import { inspect } from 'util'; +import { hasValue, hasValueOperator, isNotEmpty } from '../shared/empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { Observable } from 'rxjs/internal/Observable'; +import { ITEM_MODULE_PATH } from '../item-page/item-page-routing-paths'; +import { COLLECTION_MODULE_PATH } from '../collection-page/collection-page-routing-paths'; +import { COMMUNITY_MODULE_PATH } from '../community-page/community-page-routing-paths'; /** * This component triggers a page view statistic @@ -29,7 +36,17 @@ import { getFirstSucceededRemoteData } from '../core/shared/operators'; } ] }) -export class SearchTrackerComponent extends SearchComponent implements OnInit { +export class SearchTrackerComponent extends SearchComponent implements OnInit, OnDestroy { + /** + * Regex to match UUIDs + */ + uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/g; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + subs: Subscription[] = []; constructor( protected service: SearchService, @@ -44,8 +61,33 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit { } ngOnInit(): void { - // super.ngOnInit(); - this.getSearchOptions().pipe( + this.subs.push( + this.getSearchOptionsAndObjects().subscribe((options) => { + this.trackEvent(this.transformOptionsToEventProperties(options)); + }), + this.router.events.pipe( + filter((event) => event instanceof NavigationStart), + map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)), + hasValueOperator(), + switchMap((uuid) => + this.getSearchOptionsAndObjects().pipe( + take(1), + map((options) => this.transformOptionsToEventProperties(Object.assign({}, options, { + object: uuid, + }))) + ) + ), + ).subscribe((options) => { + this.trackEvent(options); + }), + ); + } + + /** + * Get a combination of the currently applied search options and search query response + */ + getSearchOptionsAndObjects(): Observable<{ config: PaginatedSearchOptions, searchQueryResponse: SearchObjects }> { + return this.getSearchOptions().pipe( switchMap((options: PaginatedSearchOptions) => this.service.searchEntries(options).pipe( getFirstSucceededRemoteData(), @@ -53,31 +95,70 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit { config: options, searchQueryResponse: rd.payload })) - )), - ).subscribe(({ config, searchQueryResponse }) => { - const filters: { filter: string, operator: string, value: string, label: string; }[] = []; - const appliedFilters = searchQueryResponse.appliedFilters || []; - for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { - const appliedFilter = appliedFilters[i]; - filters.push(appliedFilter); + ) + ), + ); + } + + /** + * Transform the given options containing search-options, query-response and optional object UUID into properties + * that can be sent to Angularitics for triggering a search event + * @param options + */ + transformOptionsToEventProperties(options: { config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, object?: string }): any { + const filters: { filter: string, operator: string, value: string, label: string; }[] = []; + const appliedFilters = options.searchQueryResponse.appliedFilters || []; + for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { + const appliedFilter = appliedFilters[i]; + filters.push(appliedFilter); + } + return { + action: 'search', + properties: { + searchOptions: options.config, + page: { + size: options.config.pagination.size, // same as searchQueryResponse.page.elementsPerPage + totalElements: options.searchQueryResponse.pageInfo.totalElements, + totalPages: options.searchQueryResponse.pageInfo.totalPages, + number: options.config.pagination.currentPage, // same as searchQueryResponse.page.currentPage + }, + sort: { + by: options.config.sort.field, + order: options.config.sort.direction + }, + filters: filters, + object: options.object, + }, + }; + } + + /** + * Track an event with given properties + * @param properties + */ + trackEvent(properties: any) { + this.angulartics2.eventTrack.next(properties); + } + + /** + * Get the UUID from a DSO url + * Return null if the url isn't a community, collection or item page or the UUID couldn't be found + * @param url + */ + getDsoUUIDFromUrl(url: string): string { + if (isNotEmpty(url)) { + if (url.startsWith(`/${ITEM_MODULE_PATH}`) || url.startsWith(`/${COLLECTION_MODULE_PATH}`) || url.startsWith(`/${COMMUNITY_MODULE_PATH}`)) { + const uuid = url.substring(url.lastIndexOf('/') + 1); + if (uuid.match(this.uuidRegex)) { + return uuid; } - this.angulartics2.eventTrack.next({ - action: 'search', - properties: { - searchOptions: config, - page: { - size: config.pagination.size, // same as searchQueryResponse.page.elementsPerPage - totalElements: searchQueryResponse.pageInfo.totalElements, - totalPages: searchQueryResponse.pageInfo.totalPages, - number: config.pagination.currentPage, // same as searchQueryResponse.page.currentPage - }, - sort: { - by: config.sort.field, - order: config.sort.direction - }, - filters: filters, - }, - }); - }); + } + } + return null; + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } } diff --git a/src/app/statistics/angulartics/dspace-provider.ts b/src/app/statistics/angulartics/dspace-provider.ts index cd1aab94bd..30a56ad505 100644 --- a/src/app/statistics/angulartics/dspace-provider.ts +++ b/src/app/statistics/angulartics/dspace-provider.ts @@ -31,7 +31,8 @@ export class Angulartics2DSpace { event.properties.searchOptions, event.properties.page, event.properties.sort, - event.properties.filters + event.properties.filters, + event.properties.object, ); } } diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index 9e12e627b5..b9d078ad22 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -45,12 +45,14 @@ export class StatisticsService { * @param page: An object that describes the pagination status * @param sort: An object that describes the sort status * @param filters: An array of search filters used to filter the result set + * @param object: Object clicked */ trackSearchEvent( searchOptions: SearchOptions, page: { size: number, totalElements: number, totalPages: number, number: number }, sort: { by: string, order: string }, - filters?: { filter: string, operator: string, value: string, label: string }[] + filters?: { filter: string, operator: string, value: string, label: string }[], + object?: string, ) { const body = { query: searchOptions.query, @@ -87,6 +89,9 @@ export class StatisticsService { } Object.assign(body, { appliedFilters: bodyFilters }); } + if (hasValue(object)) { + Object.assign(body, { object }); + } this.sendEvent('/statistics/searchevents', body); } From 41e4e870619e1c5a30b9d740dafa693107e4d88d Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 20 Apr 2023 10:13:55 +0200 Subject: [PATCH 2/9] 100414: Add entities to allowed object route --- src/app/search-page/search-tracker.component.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/search-page/search-tracker.component.ts b/src/app/search-page/search-tracker.component.ts index eb7ac6fcc3..6aa043cb54 100644 --- a/src/app/search-page/search-tracker.component.ts +++ b/src/app/search-page/search-tracker.component.ts @@ -42,6 +42,13 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit, O */ uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/g; + /** + * List of paths that are considered to be the start of a route to an object page (excluding "/", e.g. "items") + * These are expected to end on an object UUID + * If they match the route we're navigating to, an object property will be added to the search event sent + */ + allowedObjectPaths: string[] = ['entities', ITEM_MODULE_PATH, COLLECTION_MODULE_PATH, COMMUNITY_MODULE_PATH]; + /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} @@ -142,12 +149,12 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit, O /** * Get the UUID from a DSO url - * Return null if the url isn't a community, collection or item page or the UUID couldn't be found + * Return null if the url isn't an object page (allowedObjectPaths) or the UUID couldn't be found * @param url */ getDsoUUIDFromUrl(url: string): string { if (isNotEmpty(url)) { - if (url.startsWith(`/${ITEM_MODULE_PATH}`) || url.startsWith(`/${COLLECTION_MODULE_PATH}`) || url.startsWith(`/${COMMUNITY_MODULE_PATH}`)) { + if (this.allowedObjectPaths.some((path) => url.startsWith(`/${path}`))) { const uuid = url.substring(url.lastIndexOf('/') + 1); if (uuid.match(this.uuidRegex)) { return uuid; From 3221621e6cab233c2efdf217c6dc06c011ee48fe Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 11:17:19 +0200 Subject: [PATCH 3/9] 100414: Missing search result statistics - renaming object to clickedObject --- src/app/search-page/search-tracker.component.ts | 6 +++--- src/app/statistics/angulartics/dspace-provider.ts | 2 +- src/app/statistics/statistics.service.ts | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/search-page/search-tracker.component.ts b/src/app/search-page/search-tracker.component.ts index 6aa043cb54..1912b237ec 100644 --- a/src/app/search-page/search-tracker.component.ts +++ b/src/app/search-page/search-tracker.component.ts @@ -80,7 +80,7 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit, O this.getSearchOptionsAndObjects().pipe( take(1), map((options) => this.transformOptionsToEventProperties(Object.assign({}, options, { - object: uuid, + clickedObject: uuid, }))) ) ), @@ -112,7 +112,7 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit, O * that can be sent to Angularitics for triggering a search event * @param options */ - transformOptionsToEventProperties(options: { config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, object?: string }): any { + transformOptionsToEventProperties(options: { config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, clickedObject?: string }): any { const filters: { filter: string, operator: string, value: string, label: string; }[] = []; const appliedFilters = options.searchQueryResponse.appliedFilters || []; for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { @@ -134,7 +134,7 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit, O order: options.config.sort.direction }, filters: filters, - object: options.object, + clickedObject: options.clickedObject, }, }; } diff --git a/src/app/statistics/angulartics/dspace-provider.ts b/src/app/statistics/angulartics/dspace-provider.ts index 30a56ad505..5297f6fc38 100644 --- a/src/app/statistics/angulartics/dspace-provider.ts +++ b/src/app/statistics/angulartics/dspace-provider.ts @@ -32,7 +32,7 @@ export class Angulartics2DSpace { event.properties.page, event.properties.sort, event.properties.filters, - event.properties.object, + event.properties.clickedObject, ); } } diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts index b9d078ad22..e9c21aa37d 100644 --- a/src/app/statistics/statistics.service.ts +++ b/src/app/statistics/statistics.service.ts @@ -45,14 +45,14 @@ export class StatisticsService { * @param page: An object that describes the pagination status * @param sort: An object that describes the sort status * @param filters: An array of search filters used to filter the result set - * @param object: Object clicked + * @param clickedObject: UUID of object clicked */ trackSearchEvent( searchOptions: SearchOptions, page: { size: number, totalElements: number, totalPages: number, number: number }, sort: { by: string, order: string }, filters?: { filter: string, operator: string, value: string, label: string }[], - object?: string, + clickedObject?: string, ) { const body = { query: searchOptions.query, @@ -89,8 +89,8 @@ export class StatisticsService { } Object.assign(body, { appliedFilters: bodyFilters }); } - if (hasValue(object)) { - Object.assign(body, { object }); + if (hasValue(clickedObject)) { + Object.assign(body, { clickedObject }); } this.sendEvent('/statistics/searchevents', body); } From 0f0847c069388e92472e6dbc91be74a15de0db79 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 20 Apr 2023 11:48:16 +0200 Subject: [PATCH 4/9] 100414: Refactor SearchTrackerComponent to SearchComponent --- src/app/core/shared/search/search.service.ts | 4 +- src/app/search-page/search-page.module.ts | 2 - .../search-page/search-tracker.component.html | 1 - .../search-page/search-tracker.component.scss | 3 - .../search-page/search-tracker.component.ts | 171 ------------------ src/app/shared/search/search.component.ts | 74 ++++++-- 6 files changed, 66 insertions(+), 189 deletions(-) delete mode 100644 src/app/search-page/search-tracker.component.html delete mode 100644 src/app/search-page/search-tracker.component.scss delete mode 100644 src/app/search-page/search-tracker.component.ts diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index c8ce4b0348..2f3d3bd184 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -334,8 +334,9 @@ export class SearchService implements OnDestroy { * Send search event to rest api using angularitics * @param config Paginated search options used * @param searchQueryResponse The response objects of the performed search + * @param object Optional UUID of an object a search was performed and clicked for */ - trackSearch(config: PaginatedSearchOptions, searchQueryResponse: SearchObjects) { + trackSearch(config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, object?: string) { const filters: { filter: string, operator: string, value: string, label: string; }[] = []; const appliedFilters = searchQueryResponse.appliedFilters || []; for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { @@ -357,6 +358,7 @@ export class SearchService implements OnDestroy { order: config.sort.direction }, filters: filters, + object, }, }); } diff --git a/src/app/search-page/search-page.module.ts b/src/app/search-page/search-page.module.ts index 758eca15c0..82d4dbbe67 100644 --- a/src/app/search-page/search-page.module.ts +++ b/src/app/search-page/search-page.module.ts @@ -4,7 +4,6 @@ import { CoreModule } from '../core/core.module'; import { SharedModule } from '../shared/shared.module'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; -import { SearchTrackerComponent } from './search-tracker.component'; import { StatisticsModule } from '../statistics/statistics.module'; import { SearchPageComponent } from './search-page.component'; import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service'; @@ -17,7 +16,6 @@ import { SearchModule } from '../shared/search/search.module'; const components = [ SearchPageComponent, - SearchTrackerComponent, ThemedSearchPageComponent ]; diff --git a/src/app/search-page/search-tracker.component.html b/src/app/search-page/search-tracker.component.html deleted file mode 100644 index c0c0ffe181..0000000000 --- a/src/app/search-page/search-tracker.component.html +++ /dev/null @@ -1 +0,0 @@ -  diff --git a/src/app/search-page/search-tracker.component.scss b/src/app/search-page/search-tracker.component.scss deleted file mode 100644 index c76cafbe44..0000000000 --- a/src/app/search-page/search-tracker.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - display: none -} diff --git a/src/app/search-page/search-tracker.component.ts b/src/app/search-page/search-tracker.component.ts deleted file mode 100644 index c33496eaaa..0000000000 --- a/src/app/search-page/search-tracker.component.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; -import { Angulartics2 } from 'angulartics2'; -import { filter, map, switchMap, take } from 'rxjs/operators'; -import { SearchComponent } from '../shared/search/search.component'; -import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { HostWindowService } from '../shared/host-window.service'; -import { SEARCH_CONFIG_SERVICE } from '../my-dspace-page/my-dspace-page.component'; -import { RouteService } from '../core/services/route.service'; -import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; -import { SearchService } from '../core/shared/search/search.service'; -import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; -import { SearchObjects } from '../shared/search/models/search-objects.model'; -import { NavigationStart, Router } from '@angular/router'; -import { RemoteData } from '../core/data/remote-data'; -import { DSpaceObject } from '../core/shared/dspace-object.model'; -import { getFirstSucceededRemoteData } from '../core/shared/operators'; -import { inspect } from 'util'; -import { hasValue, hasValueOperator, isNotEmpty } from '../shared/empty.util'; -import { Subscription } from 'rxjs/internal/Subscription'; -import { Observable } from 'rxjs/internal/Observable'; -import { ITEM_MODULE_PATH } from '../item-page/item-page-routing-paths'; -import { COLLECTION_MODULE_PATH } from '../collection-page/collection-page-routing-paths'; -import { COMMUNITY_MODULE_PATH } from '../community-page/community-page-routing-paths'; - -/** - * This component triggers a page view statistic - */ -@Component({ - selector: 'ds-search-tracker', - styleUrls: ['./search-tracker.component.scss'], - templateUrl: './search-tracker.component.html', - providers: [ - { - provide: SEARCH_CONFIG_SERVICE, - useClass: SearchConfigurationService - } - ] -}) -export class SearchTrackerComponent extends SearchComponent implements OnInit, OnDestroy { - /** - * Regex to match UUIDs - */ - uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/g; - - /** - * List of paths that are considered to be the start of a route to an object page (excluding "/", e.g. "items") - * These are expected to end on an object UUID - * If they match the route we're navigating to, an object property will be added to the search event sent - */ - allowedObjectPaths: string[] = ['entities', ITEM_MODULE_PATH, COLLECTION_MODULE_PATH, COMMUNITY_MODULE_PATH]; - - /** - * Array to track all subscriptions and unsubscribe them onDestroy - * @type {Array} - */ - subs: Subscription[] = []; - - constructor( - protected service: SearchService, - protected sidebarService: SidebarService, - protected windowService: HostWindowService, - @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, - protected routeService: RouteService, - public angulartics2: Angulartics2, - protected router: Router - ) { - super(service, sidebarService, windowService, searchConfigService, routeService, router); - } - - ngOnInit(): void { - this.subs.push( - this.getSearchOptionsAndObjects().subscribe((options) => { - this.trackEvent(this.transformOptionsToEventProperties(options)); - }), - this.router.events.pipe( - filter((event) => event instanceof NavigationStart), - map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)), - hasValueOperator(), - switchMap((uuid) => - this.getSearchOptionsAndObjects().pipe( - take(1), - map((options) => this.transformOptionsToEventProperties(Object.assign({}, options, { - clickedObject: uuid, - }))) - ) - ), - ).subscribe((options) => { - this.trackEvent(options); - }), - ); - } - - /** - * Get a combination of the currently applied search options and search query response - */ - getSearchOptionsAndObjects(): Observable<{ config: PaginatedSearchOptions, searchQueryResponse: SearchObjects }> { - return this.getSearchOptions().pipe( - switchMap((options: PaginatedSearchOptions) => - this.service.searchEntries(options).pipe( - getFirstSucceededRemoteData(), - map((rd: RemoteData>) => ({ - config: options, - searchQueryResponse: rd.payload - })) - ) - ), - ); - } - - /** - * Transform the given options containing search-options, query-response and optional object UUID into properties - * that can be sent to Angularitics for triggering a search event - * @param options - */ - transformOptionsToEventProperties(options: { config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, clickedObject?: string }): any { - const filters: { filter: string, operator: string, value: string, label: string; }[] = []; - const appliedFilters = options.searchQueryResponse.appliedFilters || []; - for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { - const appliedFilter = appliedFilters[i]; - filters.push(appliedFilter); - } - return { - action: 'search', - properties: { - searchOptions: options.config, - page: { - size: options.config.pagination.size, // same as searchQueryResponse.page.elementsPerPage - totalElements: options.searchQueryResponse.pageInfo.totalElements, - totalPages: options.searchQueryResponse.pageInfo.totalPages, - number: options.config.pagination.currentPage, // same as searchQueryResponse.page.currentPage - }, - sort: { - by: options.config.sort.field, - order: options.config.sort.direction - }, - filters: filters, - clickedObject: options.clickedObject, - }, - }; - } - - /** - * Track an event with given properties - * @param properties - */ - trackEvent(properties: any) { - this.angulartics2.eventTrack.next(properties); - } - - /** - * Get the UUID from a DSO url - * Return null if the url isn't an object page (allowedObjectPaths) or the UUID couldn't be found - * @param url - */ - getDsoUUIDFromUrl(url: string): string { - if (isNotEmpty(url)) { - if (this.allowedObjectPaths.some((path) => url.startsWith(`/${path}`))) { - const uuid = url.substring(url.lastIndexOf('/') + 1); - if (uuid.match(this.uuidRegex)) { - return uuid; - } - } - } - return null; - } - - ngOnDestroy() { - super.ngOnDestroy(); - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } -} diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 2abd5290cb..1d1a6a5f2f 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { uniqueId } from 'lodash'; import { PaginatedList } from '../../core/data/paginated-list.model'; @@ -11,7 +11,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { pushInOut } from '../animations/push'; import { HostWindowService } from '../host-window.service'; import { SidebarService } from '../sidebar/sidebar.service'; -import { hasValue } from '../empty.util'; +import { hasValue, hasValueOperator, isNotEmpty } from '../empty.util'; import { RouteService } from '../../core/services/route.service'; import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; import { PaginatedSearchOptions } from './models/paginated-search-options.model'; @@ -34,6 +34,9 @@ import { CollectionElementLinkType } from '../object-collection/collection-eleme import { environment } from 'src/environments/environment'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { SearchFilterConfig } from './models/search-filter-config.model'; +import { ITEM_MODULE_PATH } from '../../item-page/item-page-routing-paths'; +import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths'; +import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths'; @Component({ selector: 'ds-search', @@ -218,9 +221,21 @@ export class SearchComponent implements OnInit { searchLink: string; /** - * Subscription to unsubscribe from + * Regex to match UUIDs */ - sub: Subscription; + uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/g; + + /** + * List of paths that are considered to be the start of a route to an object page (excluding "/", e.g. "items") + * These are expected to end on an object UUID + * If they match the route we're navigating to, an object property will be added to the search event sent + */ + allowedObjectPaths: string[] = ['entities', ITEM_MODULE_PATH, COLLECTION_MODULE_PATH, COMMUNITY_MODULE_PATH]; + + /** + * Subscriptions to unsubscribe from + */ + subs: Subscription[] = []; /** * Emits an event with the current search result entries @@ -290,7 +305,7 @@ export class SearchComponent implements OnInit { ); const searchOptions$: Observable = this.getSearchOptions().pipe(distinctUntilChanged()); - this.sub = combineLatest([configuration$, searchSortOptions$, searchOptions$, sortOption$]).pipe( + this.subs.push(combineLatest([configuration$, searchSortOptions$, searchOptions$, sortOption$]).pipe( filter(([configuration, searchSortOptions, searchOptions, sortOption]: [string, SortOptions[], PaginatedSearchOptions, SortOptions]) => { // filter for search options related to instanced paginated id return searchOptions.pagination.id === this.paginationId; @@ -318,7 +333,9 @@ export class SearchComponent implements OnInit { this.retrieveSearchResults(newSearchOptions); this.retrieveFilters(searchOptions); } - }); + })); + + this.subscribeToRoutingEvents(); } /** @@ -360,12 +377,10 @@ export class SearchComponent implements OnInit { } /** - * Unsubscribe from the subscription + * Unsubscribe from the subscriptions */ ngOnDestroy(): void { - if (hasValue(this.sub)) { - this.sub.unsubscribe(); - } + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } /** @@ -420,6 +435,43 @@ export class SearchComponent implements OnInit { }); } + /** + * Subscribe to routing events to detect when a user moves away from the search page + * When the user is routing to an object page, it needs to send out a separate search event containing that object's UUID + * This method should only be called once and is essentially what SearchTrackingComponent used to do (now removed) + * @private + */ + private subscribeToRoutingEvents() { + this.subs.push( + this.router.events.pipe( + filter((event) => event instanceof NavigationStart), + map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)), + hasValueOperator(), + ).subscribe((uuid) => { + if (this.resultsRD$.value.hasSucceeded) { + this.service.trackSearch(this.searchOptions$.value, this.resultsRD$.value.payload as SearchObjects, uuid); + } + }), + ); + } + + /** + * Get the UUID from a DSO url + * Return null if the url isn't an object page (allowedObjectPaths) or the UUID couldn't be found + * @param url + */ + private getDsoUUIDFromUrl(url: string): string { + if (isNotEmpty(url)) { + if (this.allowedObjectPaths.some((path) => url.startsWith(`/${path}`))) { + const uuid = url.substring(url.lastIndexOf('/') + 1); + if (uuid.match(this.uuidRegex)) { + return uuid; + } + } + } + return null; + } + /** * Check if the sidebar is collapsed * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded From 540ce4d7c4454f47a06c79e48893430e90da4136 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 12:29:07 +0200 Subject: [PATCH 5/9] 100414: Rename object to clickedObject --- src/app/core/shared/search/search.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 2f3d3bd184..fc433d8dd8 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -334,9 +334,9 @@ export class SearchService implements OnDestroy { * Send search event to rest api using angularitics * @param config Paginated search options used * @param searchQueryResponse The response objects of the performed search - * @param object Optional UUID of an object a search was performed and clicked for + * @param clickedObject Optional UUID of an object a search was performed and clicked for */ - trackSearch(config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, object?: string) { + trackSearch(config: PaginatedSearchOptions, searchQueryResponse: SearchObjects, clickedObject?: string) { const filters: { filter: string, operator: string, value: string, label: string; }[] = []; const appliedFilters = searchQueryResponse.appliedFilters || []; for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) { @@ -358,7 +358,7 @@ export class SearchService implements OnDestroy { order: config.sort.direction }, filters: filters, - object, + clickedObject, }, }); } From cc34e27c20db7947ea2be87f438fde1e4a827f2e Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 13:56:54 +0200 Subject: [PATCH 6/9] 100414: test cases --- .../shared/search/search.component.spec.ts | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 3f00cf354f..85d4111b78 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -13,7 +13,7 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o import { SearchComponent } from './search.component'; import { SearchService } from '../../core/shared/search/search.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, NavigationStart } from '@angular/router'; import { By } from '@angular/platform-browser'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { SidebarService } from '../sidebar/sidebar.service'; @@ -31,6 +31,10 @@ import { SearchObjects } from './models/search-objects.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { SearchFilterConfig } from './models/search-filter-config.model'; import { FilterType } from './models/filter-type.model'; +import { createPaginatedList } from '../testing/utils.test'; +import { COMMUNITY_MODULE_PATH, getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; +import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; let comp: SearchComponent; let fixture: ComponentFixture; @@ -101,8 +105,9 @@ const searchServiceStub = jasmine.createSpyObj('SearchService', { search: mockResultsRD$, getSearchLink: '/search', getScopes: observableOf(['test-scope']), - getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig) -}); + getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig), + trackSearch: {}, +}) as SearchService; const configurationParam = 'default'; const queryParam = 'test query'; const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; @@ -237,7 +242,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar }).compileComponents(); } -describe('SearchComponent', () => { +fdescribe('SearchComponent', () => { beforeEach(waitForAsync(() => { configureSearchComponentTestingModule(SearchComponent); })); @@ -327,4 +332,64 @@ describe('SearchComponent', () => { })); }); + + describe('getDsoUUIDFromUrl', () => { + let url: string; + let result: string; + + describe('when the navigated URL is an entity route', () => { + beforeEach(() => { + url = '/entities/publication/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is a community route', () => { + beforeEach(() => { + url = `${getCommunityPageRoute('9a364471-3f19-4e7b-916a-a24a44ff48e3')}`; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is a collection route', () => { + beforeEach(() => { + url = `${getCollectionPageRoute('9a364471-3f19-4e7b-916a-a24a44ff48e3')}`; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is an item route', () => { + beforeEach(() => { + url = '/items/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is an invalid route', () => { + beforeEach(() => { + url = '/invalid/object/route/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return null', () => { + expect(result).toBeNull(); + }); + }); + }); }); From 6675b61f81220b8ee46566182567f0bf764accb8 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 13:56:54 +0200 Subject: [PATCH 7/9] 100414: test cases --- .../shared/search/search.component.spec.ts | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 3f00cf354f..85d4111b78 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -13,7 +13,7 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o import { SearchComponent } from './search.component'; import { SearchService } from '../../core/shared/search/search.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, NavigationStart } from '@angular/router'; import { By } from '@angular/platform-browser'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { SidebarService } from '../sidebar/sidebar.service'; @@ -31,6 +31,10 @@ import { SearchObjects } from './models/search-objects.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { SearchFilterConfig } from './models/search-filter-config.model'; import { FilterType } from './models/filter-type.model'; +import { createPaginatedList } from '../testing/utils.test'; +import { COMMUNITY_MODULE_PATH, getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; +import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; let comp: SearchComponent; let fixture: ComponentFixture; @@ -101,8 +105,9 @@ const searchServiceStub = jasmine.createSpyObj('SearchService', { search: mockResultsRD$, getSearchLink: '/search', getScopes: observableOf(['test-scope']), - getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig) -}); + getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig), + trackSearch: {}, +}) as SearchService; const configurationParam = 'default'; const queryParam = 'test query'; const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; @@ -237,7 +242,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar }).compileComponents(); } -describe('SearchComponent', () => { +fdescribe('SearchComponent', () => { beforeEach(waitForAsync(() => { configureSearchComponentTestingModule(SearchComponent); })); @@ -327,4 +332,64 @@ describe('SearchComponent', () => { })); }); + + describe('getDsoUUIDFromUrl', () => { + let url: string; + let result: string; + + describe('when the navigated URL is an entity route', () => { + beforeEach(() => { + url = '/entities/publication/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is a community route', () => { + beforeEach(() => { + url = `${getCommunityPageRoute('9a364471-3f19-4e7b-916a-a24a44ff48e3')}`; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is a collection route', () => { + beforeEach(() => { + url = `${getCollectionPageRoute('9a364471-3f19-4e7b-916a-a24a44ff48e3')}`; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is an item route', () => { + beforeEach(() => { + url = '/items/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return the UUID', () => { + expect(result).toEqual('9a364471-3f19-4e7b-916a-a24a44ff48e3'); + }); + }); + + describe('when the navigated URL is an invalid route', () => { + beforeEach(() => { + url = '/invalid/object/route/9a364471-3f19-4e7b-916a-a24a44ff48e3'; + result = (comp as any).getDsoUUIDFromUrl(url); + }); + + it('should return null', () => { + expect(result).toBeNull(); + }); + }); + }); }); From f5b7bea3ec7fb17e15cab82ee1d37918594c3813 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 14:08:05 +0200 Subject: [PATCH 8/9] 100414: Lint fixes --- src/app/shared/search/search.component.spec.ts | 6 ++---- src/app/shared/search/search.component.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index 85d4111b78..ff849a17d2 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -13,7 +13,7 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o import { SearchComponent } from './search.component'; import { SearchService } from '../../core/shared/search/search.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute, NavigationStart } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { By } from '@angular/platform-browser'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { SidebarService } from '../sidebar/sidebar.service'; @@ -31,10 +31,8 @@ import { SearchObjects } from './models/search-objects.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { SearchFilterConfig } from './models/search-filter-config.model'; import { FilterType } from './models/filter-type.model'; -import { createPaginatedList } from '../testing/utils.test'; -import { COMMUNITY_MODULE_PATH, getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; +import { getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths'; -import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; let comp: SearchComponent; let fixture: ComponentFixture; diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 1d1a6a5f2f..e376c4c429 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit import { NavigationStart, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { uniqueId } from 'lodash'; import { PaginatedList } from '../../core/data/paginated-list.model'; From c306dfd30927e5bf0f9881f26b51ca64f0498cd5 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 May 2023 14:10:20 +0200 Subject: [PATCH 9/9] 100414: remove fdescribe --- src/app/shared/search/search.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index ff849a17d2..d0d9bdda86 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -240,7 +240,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar }).compileComponents(); } -fdescribe('SearchComponent', () => { +describe('SearchComponent', () => { beforeEach(waitForAsync(() => { configureSearchComponentTestingModule(SearchComponent); }));