mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2025-07-24 17:31:52 +00:00
[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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
],
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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'), '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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>
|
||||
|
@@ -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*")`);
|
||||
|
@@ -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']) {
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user