[ACS-6427] Add search highlighting (#3637)

* [ACS-6427] Initial commit for search highlights

* [ACS-6427] Add correct highlighting config, handle highlights in search results

* [ACS-6427] CR fixes

* [ACS-6427] CR fix

* [ACS-6427] Locator fix

* [ACS-6427] E2E fix

---------

Co-authored-by: swapnil.verma <swapnil.verma@globallogic.com>
This commit is contained in:
MichalKinas
2024-02-15 16:26:09 +01:00
committed by GitHub
parent 60a4abaa55
commit 4766bfe707
12 changed files with 303 additions and 85 deletions

View File

@@ -1532,7 +1532,27 @@
"visible": "app.areCategoriesEnabled"
}
}
]
],
"highlight": {
"prefix": "<span class='aca-highlight'>",
"postfix": "</span>",
"fields": [
{
"field": "cm:title"
},
{
"field": "cm:name"
},
{
"field": "cm:description",
"snippetCount": 1
},
{
"field": "cm:content",
"snippetCount": 1
}
]
}
},
{
"id": "app.search.dublin-core",
@@ -1688,7 +1708,27 @@
}
}
}
]
],
"highlight": {
"prefix": "<span class='aca-highlight'>",
"postfix": "</span>",
"fields": [
{
"field": "cm:title"
},
{
"field": "cm:name"
},
{
"field": "cm:description",
"snippetCount": 1
},
{
"field": "cm:content",
"snippetCount": 1
}
]
}
},
{
"id": "app.search.effectivity",
@@ -1846,7 +1886,27 @@
}
}
}
]
],
"highlight": {
"prefix": "<span class='aca-highlight'>",
"postfix": "</span>",
"fields": [
{
"field": "cm:title"
},
{
"field": "cm:name"
},
{
"field": "cm:description",
"snippetCount": 1
},
{
"field": "cm:content",
"snippetCount": 1
}
]
}
}
],

View File

@@ -1,12 +1,38 @@
<div class="search-file-name">
<span tabindex="0" role="link" *ngIf="isFile" (click)="showPreview($event)" (keyup.enter)="showPreview($event)" class="aca-link">
{{ name$ | async }}
</span>
<span tabindex="0" role="link" *ngIf="!isFile" (click)="navigate($event)" (keyup.enter)="navigate($event)" class="bold aca-link">
{{ name$ | async }}
</span>
<span>{{ title$ | async }}</span>
<span
tabindex="0"
role="link"
*ngIf="isFile"
(click)="showPreview($event)"
(keyup.enter)="showPreview($event)"
class="aca-link aca-crop-text"
[title]="nameStripped"
[innerHTML]="name$ | async"
></span>
<span
tabindex="0"
role="link"
*ngIf="!isFile"
(click)="navigate($event)"
(keyup.enter)="navigate($event)"
class="bold aca-link aca-crop-text"
[title]="nameStripped"
[innerHTML]="name$ | async"
></span>
<span
data-automation-id="search-results-entry-title"
class="aca-crop-text"
[title]="titleStripped"
[innerHTML]="title$ | async"
></span>
</div>
<div
data-automation-id="search-results-entry-description"
class="aca-crop-text"
[title]="descriptionStripped"
[innerHTML]="description$ | async"
></div>
<div class="aca-result-location">
<aca-location-link [context]="context" [showLocation]="true"></aca-location-link>
</div>
<div class="aca-result-content aca-crop-text" [title]="contentStripped" [innerHTML]="content$ | async"></div>

View File

@@ -1,9 +1,26 @@
.aca-search-results-row {
padding: 10px 0;
width: inherit;
.aca-highlight {
background: var(--theme-search-highlight-background-color);
}
.aca-crop-text {
overflow: hidden;
text-overflow: ellipsis;
}
.aca-result-location {
height: 15px;
padding-top: 3px;
}
.aca-result-content {
padding: 0 5px;
font-style: italic;
}
.aca-link {
text-decoration: none;
color: var(--theme-text-bold-color);

View File

@@ -0,0 +1,123 @@
/*!
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { NodeEntry, ResultSetRowEntry } from '@alfresco/js-api';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { first } from 'rxjs/operators';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchResultsRowComponent } from './search-results-row.component';
describe('SearchResultsRowComponent', () => {
let component: SearchResultsRowComponent;
let fixture: ComponentFixture<SearchResultsRowComponent>;
const nodeEntry: NodeEntry = {
entry: {
id: 'fake-node-entry',
modifiedByUser: { displayName: 'IChangeThings' },
modifiedAt: new Date(),
isFile: true,
properties: { 'cm:title': 'BananaRama' }
}
} as NodeEntry;
const resultEntry: ResultSetRowEntry = {
entry: {
id: 'fake-node-entry',
modifiedAt: new Date(),
isFile: true,
name: 'Random name',
properties: { 'cm:title': 'Random title', 'cm:description': 'some random description' },
search: {
score: 10,
highlight: [
{
field: 'cm:content',
snippets: [`Interesting <span class='aca-highlight'>random</span> content`]
},
{
field: 'cm:name',
snippets: [`<span class='aca-highlight'>Random</span>`]
},
{
field: 'cm:title',
snippets: [`<span class='aca-highlight'>Random</span> title`]
},
{
field: 'cm:description',
snippets: [`some <span class='aca-highlight'>random</span> description`]
}
]
}
}
} as ResultSetRowEntry;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, SearchResultsRowComponent]
});
fixture = TestBed.createComponent(SearchResultsRowComponent);
component = fixture.componentInstance;
});
it('should show the current node', () => {
component.context = { row: { node: nodeEntry } };
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).not.toBeNull();
});
it('should correctly parse highlights', (done) => {
component.context = { row: { node: resultEntry } };
component.content$
.asObservable()
.pipe(first())
.subscribe(() => {
fixture.detectChanges();
const nameElement: HTMLSpanElement = fixture.debugElement.query(By.css('.aca-link.aca-crop-text')).nativeElement;
expect(nameElement.innerHTML).toBe('<span class="aca-highlight">Random</span>');
expect(nameElement.title).toBe('Random');
const titleElement: HTMLSpanElement = fixture.debugElement.query(By.css('[data-automation-id="search-results-entry-title"]')).nativeElement;
expect(titleElement.innerHTML).toBe(' ( <span class="aca-highlight">Random</span> title )');
expect(titleElement.title).toBe('Random title');
const descriptionElement: HTMLDivElement = fixture.debugElement.query(
By.css('[data-automation-id="search-results-entry-description"]')
).nativeElement;
expect(descriptionElement.innerHTML).toBe('some <span class="aca-highlight">random</span> description');
expect(descriptionElement.title).toBe('some random description');
const contentElement: HTMLDivElement = fixture.debugElement.query(By.css('.aca-result-content.aca-crop-text')).nativeElement;
expect(contentElement.innerHTML).toBe('...Interesting <span class="aca-highlight">random</span> content...');
expect(contentElement.title).toBe('...Interesting random content...');
done();
});
fixture.detectChanges();
});
});

View File

@@ -23,7 +23,7 @@
*/
import { Component, Input, OnInit, ViewEncapsulation, ChangeDetectionStrategy, OnDestroy } from '@angular/core';
import { NodeEntry } from '@alfresco/js-api';
import { NodeEntry, SearchEntryHighlight } from '@alfresco/js-api';
import { ViewNodeAction, NavigateToFolder } from '@alfresco/aca-shared/store';
import { Store } from '@ngrx/store';
import { BehaviorSubject, Subject } from 'rxjs';
@@ -46,6 +46,9 @@ import { MatDialogModule } from '@angular/material/dialog';
host: { class: 'aca-search-results-row' }
})
export class SearchResultsRowComponent implements OnInit, OnDestroy {
private readonly highlightPrefix = "<span class='aca-highlight'>";
private readonly highlightPostfix = '</span>';
private node: NodeEntry;
private onDestroy$ = new Subject<boolean>();
@@ -54,6 +57,12 @@ export class SearchResultsRowComponent implements OnInit, OnDestroy {
name$ = new BehaviorSubject<string>('');
title$ = new BehaviorSubject<string>('');
description$ = new BehaviorSubject<string>('');
content$ = new BehaviorSubject<string>('');
nameStripped = '';
titleStripped = '';
descriptionStripped = '';
contentStripped = '';
isFile = false;
@@ -86,13 +95,42 @@ export class SearchResultsRowComponent implements OnInit, OnDestroy {
this.node = this.context.row.node;
this.isFile = this.node.entry.isFile;
const { name, properties } = this.node.entry;
const title = properties ? properties['cm:title'] : '';
const highlights: SearchEntryHighlight[] = this.node.entry['search']?.['highlight'];
let name = this.node.entry.name;
const properties = this.node.entry.properties;
let title = properties?.['cm:title'] || '';
let description = properties?.['cm:description'] || '';
let content = '';
highlights?.forEach((highlight) => {
switch (highlight.field) {
case 'cm:name':
name = highlight.snippets[0];
break;
case 'cm:title':
title = highlight.snippets[0];
break;
case 'cm:description':
description = highlight.snippets[0];
break;
case 'cm:content':
content = `...${highlight.snippets[0]}...`;
break;
default:
break;
}
});
this.name$.next(name);
this.description$.next(description);
this.content$.next(content);
this.nameStripped = this.stripHighlighting(name);
this.descriptionStripped = this.stripHighlighting(description);
this.contentStripped = this.stripHighlighting(content);
if (title !== name) {
this.title$.next(title ? `( ${title} )` : '');
this.title$.next(title ? ` ( ${title} )` : '');
this.titleStripped = this.stripHighlighting(title);
}
}
@@ -114,4 +152,10 @@ export class SearchResultsRowComponent implements OnInit, OnDestroy {
event.stopPropagation();
this.store.dispatch(new NavigateToFolder(this.node));
}
private stripHighlighting(highlightedContent: string): string {
return highlightedContent
? highlightedContent.replace(new RegExp(this.highlightPrefix, 'g'), '').replace(new RegExp(this.highlightPostfix, 'g'), '')
: '';
}
}

View File

@@ -1,59 +0,0 @@
/*!
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { NodeEntry } from '@alfresco/js-api';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchResultsRowComponent } from './search-results-row.component';
describe('SearchResultsRowComponent', () => {
let component: SearchResultsRowComponent;
let fixture: ComponentFixture<SearchResultsRowComponent>;
const nodeEntry: NodeEntry = {
entry: {
id: 'fake-node-entry',
modifiedByUser: { displayName: 'IChangeThings' },
modifiedAt: new Date(),
isFile: true,
properties: { 'cm:title': 'BananaRama' }
}
} as NodeEntry;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, SearchResultsRowComponent]
});
fixture = TestBed.createComponent(SearchResultsRowComponent);
component = fixture.componentInstance;
});
it('should show the current node', () => {
component.context = { row: { node: nodeEntry } };
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).not.toBeNull();
});
});

View File

@@ -64,14 +64,6 @@
</ng-template>
</data-column>
<data-column id="app.search.description" key="properties" title="Description" class="adf-expand-cell-3" [sortable]="false" *ngIf="!isSmallScreen" [draggable]="true">
<ng-template let-context>
<span class="adf-datatable-cell-value adf-ellipsis-cell">
{{context.row?.node?.entry?.properties && context.row?.node?.entry?.properties['cm:description']}}
</span>
</ng-template>
</data-column>
<data-column id="app.search.size" key="content.sizeInBytes" type="fileSize" title="APP.DOCUMENT_LIST.COLUMNS.SIZE" class="adf-no-grow-cell adf-ellipsis-cell" [sortable]="false" *ngIf="!isSmallScreen" [draggable]="true"></data-column>
<data-column id="app.search.modifiedOn" key="modifiedAt" type="date" title="APP.DOCUMENT_LIST.COLUMNS.MODIFIED_ON" class="adf-no-grow-cell adf-ellipsis-cell" format="timeAgo" [sortable]="false" *ngIf="!isSmallScreen" [draggable]="true"></data-column>
<data-column id="app.search.modifiedBy" key="modifiedByUser.displayName" title="APP.DOCUMENT_LIST.COLUMNS.MODIFIED_BY" class="adf-no-grow-cell adf-ellipsis-cell" [sortable]="false" *ngIf="!isSmallScreen" [draggable]="true"></data-column>

View File

@@ -178,6 +178,11 @@ describe('SearchComponent', () => {
expect(query).toBe(`(cm:name:"hello*" OR cm:title:"hello*")`);
});
it('should not apply suffix to the TEXT field for correct highlighting', () => {
const query = component.formatSearchQuery('hello', ['cm:name', 'TEXT']);
expect(query).toBe(`(cm:name:"hello*" OR TEXT:"hello")`);
});
it('should format user input as cm:name if configuration not provided', () => {
const query = component.formatSearchQuery('hello');
expect(query).toBe(`(cm:name:"hello*")`);

View File

@@ -206,7 +206,15 @@ export class SearchResultsComponent extends PageComponent implements OnInit {
term = term.substring(1);
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
return (
'(' +
fields
.map((field) => {
return field !== 'TEXT' ? `${prefix}${field}:"${term}${suffix}"` : `${prefix}${field}:"${term}"`;
})
.join(' OR ') +
')'
);
}
formatSearchQuery(userInput: string, fields = ['cm:name']) {

View File

@@ -41,6 +41,7 @@ $page-layout-header-background-color: $background-card-color;
$search-chip-icon-color: #757575;
$disabled-chip-background-color: #f5f5f5;
$contrast-gray: mat.get-color-from-palette($foreground, 'secondary-tex');
$search-highlight-background-color: #ffd180;
// CSS Variables
$defaults: (
@@ -86,7 +87,8 @@ $defaults: (
--theme-page-layout-header-background-color: $page-layout-header-background-color,
--theme-search-chip-icon-color: $search-chip-icon-color,
--theme-disabled-chip-background-color: $disabled-chip-background-color,
--theme-secondary-text: $secondary-text
--theme-secondary-text: $secondary-text,
--theme-search-highlight-background-color: $search-highlight-background-color
);
// propagates SCSS variables into the CSS variables scope