[ACS-4753] list of tags is not rendered because of missing count field in backend response (#8346)

* ACS-4753 Use TagsAPI instead of SearchAPI to get counters

* ACS-4753 Combined fetching tags and counters in single http call

* ACS-4753 Added unit tests

* ACS-4753 Added documentation
This commit is contained in:
AleksanderSklorz 2023-03-16 15:52:29 +01:00 committed by GitHub
parent b472114edf
commit db777130bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 144 additions and 213 deletions

View File

@ -18,9 +18,10 @@ Manages tags in Content Services.
- _nodeId:_ `string` - ID of the target node - _nodeId:_ `string` - ID of the target node
- _tagName:_ `string` - Name of the tag to add - _tagName:_ `string` - Name of the tag to add
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagEntry.md)`>` - TagEntry object (defined in JS-API) with details of the new tag - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagEntry.md)`>` - TagEntry object (defined in JS-API) with details of the new tag
- **getAllTheTags**(opts?: `any`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>`<br/> - **getAllTheTags**(opts?: `any`, includedCounts?: `boolean`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>`<br/>
Gets a list of all the tags already defined in the repository. Gets a list of all the tags already defined in the repository.
- _opts:_ `any` - (Optional) Options supported by JS-API - _opts:_ `any` - (Optional) Options supported by JS-API
- _includedCounts:_ `boolean` - (Optional) True if count field should be included in response object for each tag, false otherwise.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>` - TagPaging object (defined in JS-API) containing the tags - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>` - TagPaging object (defined in JS-API) containing the tags
- **getTagsByNodeId**(nodeId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>`<br/> - **getTagsByNodeId**(nodeId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TagPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/TagPaging.md)`>`<br/>
Gets a list of tags added to a node. Gets a list of tags added to a node.
@ -44,11 +45,13 @@ Manages tags in Content Services.
- _tagId:_ `string` - The identifier of a tag. - _tagId:_ `string` - The identifier of a tag.
- _tagBody:_ `TagBody` - The updated tag. - _tagBody:_ `TagBody` - The updated tag.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<TagEntry>` - Updated tag. - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<TagEntry>` - Updated tag.
- **searchTags**(name: `string`, skipCount: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetPaging>`<br/> - **searchTags**(name: `string`, sorting?: `{ orderBy: string, direction: string }`, includedCounts?: `boolean`, skipCount: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetPaging>`<br/>
Find tags which name contains searched name. Find tags which name contains searched name.
- _name:_ `string` - Value for name which should be used during searching tags. - _name:_ `string` - Value for name which should be used during searching tags.
- _skipCount:_ `number` - Specify how many first results should be skipped. Default 0. - _sorting:_ `{ orderBy: string, direction: string }` - Object which configures sorting. OrderBy field specifies field used for sorting, direction specified ascending or descending direction. Default sorting is ascending by tag field.
- _maxItems:_ `number` - Specify max number of returned tags. Default is specified by UserPreferencesService. - _includedCounts:_ `boolean` - True if count field should be included in response object for each tag, false otherwise.
- _skipCount:_ `number` - Specify how many first results should be skipped. Default 0.
- _maxItems:_ `number` - Specify max number of returned tags. Default is specified by UserPreferencesService.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetPaging>` - Found tags which name contains searched name. - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetPaging>` - Found tags which name contains searched name.
- **getCountersForTags**(tags: `string[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetContextFacetQueries[]>`<br/> - **getCountersForTags**(tags: `string[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<ResultSetContextFacetQueries[]>`<br/>
Get usage counters for passed tags. Get usage counters for passed tags.

View File

@ -21,22 +21,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ContentTestingModule } from '../../testing/content.testing.module'; import { ContentTestingModule } from '../../testing/content.testing.module';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { throwError } from 'rxjs'; import { throwError } from 'rxjs';
import { import { Pagination, Tag, TagBody, TagEntry, TagPaging, TagPagingList } from '@alfresco/js-api';
Pagination,
RequestQuery,
RequestSortDefinitionInner,
ResultNode,
ResultSetContext,
ResultSetContextFacetQueries,
ResultSetPaging,
ResultSetPagingList,
ResultSetRowEntry,
Tag,
TagBody,
TagEntry,
TagPaging,
TagPagingList
} from '@alfresco/js-api';
describe('TagService', () => { describe('TagService', () => {
@ -44,6 +29,18 @@ describe('TagService', () => {
let logService: LogService; let logService: LogService;
let userPreferencesService: UserPreferencesService; let userPreferencesService: UserPreferencesService;
const mockTagPaging = (): TagPaging => {
const tagPaging = new TagPaging();
tagPaging.list = new TagPagingList();
const tag = new TagEntry();
tag.entry = new Tag();
tag.entry.id = 'some id';
tag.entry.tag = 'some name';
tagPaging.list.entries = [tag];
tagPaging.list.pagination = new Pagination();
return tagPaging;
};
setupTestBed({ setupTestBed({
imports: [ imports: [
TranslateModule.forRoot(), TranslateModule.forRoot(),
@ -135,58 +132,44 @@ describe('TagService', () => {
})); }));
}); });
describe('searchTags', () => { describe('getAllTheTags', () => {
let result: ResultSetPaging; let result: TagPaging;
beforeEach(() => { beforeEach(() => {
result = new ResultSetPaging(); result = mockTagPaging();
result.list = new ResultSetPagingList();
const tag = new ResultSetRowEntry();
tag.entry = new ResultNode();
tag.entry.id = 'some id';
tag.entry.name = 'some name';
result.list.entries = [tag];
result.list.pagination = new Pagination();
}); });
it('should call search on searchApi with correct parameters', () => { it('should call listTags on TagsApi with correct parameters when includedCounts is true', () => {
const searchSpy = spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result)); spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
const name = 'test'; const skipCount = 10;
const maxItems = 25;
spyOnProperty(userPreferencesService, 'paginationSize').and.returnValue(maxItems);
const sortingByName = new RequestSortDefinitionInner(); service.getAllTheTags({
sortingByName.field = 'cm:name'; skipCount
sortingByName.ascending = true; }, true);
sortingByName.type = RequestSortDefinitionInner.TypeEnum.FIELD; expect(service.tagsApi.listTags).toHaveBeenCalledWith({
include: ['count'],
skipCount
});
});
service.searchTags(name); it('should call listTags on TagsApi with correct parameters when includedCounts is false', () => {
expect(searchSpy).toHaveBeenCalledWith({ spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
query: { const skipCount = 10;
language: RequestQuery.LanguageEnum.Afts,
query: `PATH:"/cm:categoryRoot/cm:taggable/*" AND cm:name:"*${name}*"` service.getAllTheTags({
}, skipCount
paging: { }, false);
skipCount: 0, expect(service.tagsApi.listTags).toHaveBeenCalledWith({
maxItems include: undefined,
}, skipCount
sort: [sortingByName]
}); });
}); });
it('should return observable which emits paging object for tags', fakeAsync(() => { it('should return observable which emits paging object for tags', fakeAsync(() => {
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result)); spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
service.searchTags('test').subscribe((tagsResult) => { service.getAllTheTags().subscribe((tagsResult) => {
const tagPaging = new TagPaging(); expect(tagsResult).toEqual(result);
tagPaging.list = new TagPagingList();
const tagEntry = new TagEntry();
tagEntry.entry = new Tag();
tagEntry.entry.id = 'some id';
tagEntry.entry.tag = 'some name';
tagPaging.list.entries = [tagEntry];
tagPaging.list.pagination = new Pagination();
expect(tagsResult).toEqual(tagPaging);
}); });
tick(); tick();
})); }));
@ -194,7 +177,78 @@ describe('TagService', () => {
it('should call error on logService when error occurs during fetching paging object for tags', fakeAsync(() => { it('should call error on logService when error occurs during fetching paging object for tags', fakeAsync(() => {
spyOn(logService, 'error'); spyOn(logService, 'error');
const error: string = 'Some error'; const error: string = 'Some error';
spyOn(service.searchApi, 'search').and.returnValue(Promise.reject(error)); spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.reject(error));
service.getAllTheTags().subscribe({
error: () => {
expect(logService.error).toHaveBeenCalledWith(error);
}
});
tick();
}));
});
describe('searchTags', () => {
let result: TagPaging;
beforeEach(() => {
result = mockTagPaging();
});
it('should call listTags on TagsApi with correct default parameters', () => {
spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
const name = 'test';
const maxItems = 25;
spyOnProperty(userPreferencesService, 'paginationSize').and.returnValue(maxItems);
service.searchTags(name);
expect(service.tagsApi.listTags).toHaveBeenCalledWith({
tag: `*${name}*`,
skipCount: 0,
maxItems,
sorting: {
orderBy: 'tag',
direction: 'asc'
},
matching: true,
include: undefined
});
});
it('should call listTags on TagsApi with correct specified parameters', () => {
spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
const name = 'test';
spyOnProperty(userPreferencesService, 'paginationSize').and.returnValue(25);
const maxItems = 100;
const skipCount = 200;
const sorting = {
orderBy: 'id',
direction: 'asc'
};
service.searchTags(name, sorting, true, skipCount, maxItems);
expect(service.tagsApi.listTags).toHaveBeenCalledWith({
tag: `*${name}*`,
skipCount,
maxItems,
sorting,
matching: true,
include: ['count']
});
});
it('should return observable which emits paging object for tags', fakeAsync(() => {
spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.resolve(result));
service.searchTags('test').subscribe((tagsResult) => {
expect(tagsResult).toEqual(result);
});
tick();
}));
it('should call error on logService when error occurs during fetching paging object for tags', fakeAsync(() => {
spyOn(logService, 'error');
const error: string = 'Some error';
spyOn(service.tagsApi, 'listTags').and.returnValue(Promise.reject(error));
service.searchTags('test').subscribe({ service.searchTags('test').subscribe({
error: () => { error: () => {
expect(logService.error).toHaveBeenCalledWith(error); expect(logService.error).toHaveBeenCalledWith(error);
@ -244,83 +298,6 @@ describe('TagService', () => {
})); }));
}); });
describe('getCountersForTags', () => {
let result: ResultSetPaging;
const tag1 = 'tag 1';
beforeEach(() => {
result = new ResultSetPaging();
result.list = new ResultSetPagingList();
result.list.context = new ResultSetContext();
const facetQuery = new ResultSetContextFacetQueries();
facetQuery.count = 2;
facetQuery.label = tag1;
facetQuery.filterQuery = `TAG:"${tag1}"`;
result.list.context.facetQueries = [facetQuery];
});
it('should call search on searchApi with correct parameters', () => {
const tag2 = 'tag 2';
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result));
service.getCountersForTags([tag1, tag2]);
expect(service.searchApi.search).toHaveBeenCalledWith({
query: {
language: RequestQuery.LanguageEnum.Afts,
query: `*`
},
facetQueries: [{
query: `TAG:"${tag1}"`,
label: tag1
}, {
query: `TAG:"${tag2}"`,
label: tag2
}]
});
});
it('should return observable which emits facet queries with counters for tags', (done) => {
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result));
service.getCountersForTags([tag1]).subscribe((counters) => {
expect(counters).toBe(result.list.context.facetQueries);
done();
});
});
it('should return observable which emits undefined if context is not present', (done) => {
result.list.context = undefined;
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result));
service.getCountersForTags([tag1]).subscribe((counters) => {
expect(counters).toBeUndefined();
done();
});
});
it('should return observable which emits undefined if list is not present', (done) => {
result.list = undefined;
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve(result));
service.getCountersForTags([tag1]).subscribe((counters) => {
expect(counters).toBeUndefined();
done();
});
});
it('should call error on logService when error occurs during fetching counters for tags', fakeAsync(() => {
spyOn(logService, 'error');
const error = 'Some error';
spyOn(service.searchApi, 'search').and.returnValue(Promise.reject(error));
service.getCountersForTags([tag1]).subscribe({
error: () => {
expect(logService.error).toHaveBeenCalledWith(error);
}
});
tick();
}));
});
describe('findTagByName', () => { describe('findTagByName', () => {
let tagPaging: TagPaging; let tagPaging: TagPaging;
const tagName = 'some tag'; const tagName = 'some tag';
@ -335,7 +312,8 @@ describe('TagService', () => {
service.findTagByName(tagName); service.findTagByName(tagName);
expect(service.tagsApi.listTags).toHaveBeenCalledWith({ expect(service.tagsApi.listTags).toHaveBeenCalledWith({
name: tagName tag: tagName,
include: undefined
}); });
}); });

View File

@ -19,18 +19,7 @@ import { AlfrescoApiService, LogService, UserPreferencesService } from '@alfresc
import { EventEmitter, Injectable, Output } from '@angular/core'; import { EventEmitter, Injectable, Output } from '@angular/core';
import { from, Observable, throwError } from 'rxjs'; import { from, Observable, throwError } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators'; import { catchError, map, tap } from 'rxjs/operators';
import { import { TagBody, TagEntry, TagPaging, TagsApi } from '@alfresco/js-api';
RequestQuery,
RequestSortDefinitionInner,
ResultSetContextFacetQueries,
SearchApi,
Tag,
TagBody,
TagEntry,
TagPaging,
TagPagingList,
TagsApi
} from '@alfresco/js-api';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -43,12 +32,6 @@ export class TagService {
return this._tagsApi; return this._tagsApi;
} }
private _searchApi: SearchApi;
get searchApi(): SearchApi {
this._searchApi = this._searchApi ?? new SearchApi(this.apiService.getInstance());
return this._searchApi;
}
/** Emitted when tag information is updated. */ /** Emitted when tag information is updated. */
@Output() @Output()
refresh = new EventEmitter(); refresh = new EventEmitter();
@ -74,11 +57,14 @@ export class TagService {
* Gets a list of all the tags already defined in the repository. * Gets a list of all the tags already defined in the repository.
* *
* @param opts Options supported by JS-API * @param opts Options supported by JS-API
* @param includedCounts True if count field should be included in response object for each tag, false otherwise.
* @returns TagPaging object (defined in JS-API) containing the tags * @returns TagPaging object (defined in JS-API) containing the tags
*/ */
getAllTheTags(opts?: any): Observable<TagPaging> { getAllTheTags(opts?: any, includedCounts?: boolean): Observable<TagPaging> {
return from(this.tagsApi.listTags(opts)) return from(this.tagsApi.listTags({
.pipe(catchError((err) => this.handleError(err))); include: includedCounts ? ['count'] : undefined,
...opts
})).pipe(catchError((err) => this.handleError(err)));
} }
/** /**
@ -157,61 +143,25 @@ export class TagService {
* Find tags which name contains searched name. * Find tags which name contains searched name.
* *
* @param name Value for name which should be used during searching tags. * @param name Value for name which should be used during searching tags.
* @param sorting Object which configures sorting. OrderBy field specifies field used for sorting, direction specified ascending or descending direction.
* Default sorting is ascending by tag field.
* @param includedCounts True if count field should be included in response object for each tag, false otherwise.
* @param skipCount Specify how many first results should be skipped. Default 0. * @param skipCount Specify how many first results should be skipped. Default 0.
* @param maxItems Specify max number of returned tags. Default is specified by UserPreferencesService. * @param maxItems Specify max number of returned tags. Default is specified by UserPreferencesService.
* @returns Found tags which name contains searched name. * @returns Found tags which name contains searched name.
*/ */
searchTags(name: string, skipCount = 0, maxItems?: number): Observable<TagPaging> { searchTags(name: string, sorting = {
orderBy: 'tag',
direction: 'asc'
}, includedCounts?: boolean, skipCount = 0, maxItems?: number): Observable<TagPaging> {
maxItems = maxItems || this.userPreferencesService.paginationSize; maxItems = maxItems || this.userPreferencesService.paginationSize;
const sortingByName: RequestSortDefinitionInner = new RequestSortDefinitionInner(); return this.getAllTheTags({
sortingByName.field = 'cm:name'; tag: `*${name}*`,
sortingByName.ascending = true; skipCount,
sortingByName.type = RequestSortDefinitionInner.TypeEnum.FIELD; maxItems,
return from(this.searchApi.search({ sorting,
query: { matching: true
language: RequestQuery.LanguageEnum.Afts, }, includedCounts).pipe(catchError((err) => this.handleError(err)));
query: `PATH:"/cm:categoryRoot/cm:taggable/*" AND cm:name:"*${name}*"`
},
paging: {
skipCount,
maxItems
},
sort: [sortingByName]
})).pipe(map((resultSetPaging) => {
const tagPaging = new TagPaging();
tagPaging.list = new TagPagingList();
tagPaging.list.pagination = resultSetPaging.list.pagination;
tagPaging.list.entries = resultSetPaging.list.entries.map((resultEntry) => {
const tagEntry = new TagEntry();
tagEntry.entry = new Tag();
tagEntry.entry.tag = resultEntry.entry.name;
tagEntry.entry.id = resultEntry.entry.id;
return tagEntry;
});
return tagPaging;
}), catchError((error) => this.handleError(error)));
}
/**
* Get usage counters for passed tags.
*
* @param tags Array of tags names for which there should be returned counters.
* @returns Array of usage counters for specified tags.
*/
getCountersForTags(tags: string[]): Observable<ResultSetContextFacetQueries[]> {
return from(this.searchApi.search({
query: {
language: RequestQuery.LanguageEnum.Afts,
query: `*`
},
facetQueries: tags.map((tag) => ({
query: `TAG:"${tag}"`,
label: tag
}))
})).pipe(
map((paging) => paging.list?.context?.facetQueries),
catchError((error) => this.handleError(error))
);
} }
/** /**
@ -221,7 +171,7 @@ export class TagService {
* @returns Found tag which name matches exactly to passed name. * @returns Found tag which name matches exactly to passed name.
*/ */
findTagByName(name: string): Observable<TagEntry> { findTagByName(name: string): Observable<TagEntry> {
return this.getAllTheTags({ name }).pipe( return this.getAllTheTags({ tag: name }).pipe(
map((result) => result.list.entries[0]), map((result) => result.list.entries[0]),
catchError((error) => this.handleError(error)) catchError((error) => this.handleError(error))
); );