Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ACS-6427] Add search highlighting #3637

Merged
merged 6 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/protractor/suites/search/search-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ describe('Search filters', () => {
await peopleFilter.closeDialog();

await searchInput.clickSearchButton();
await searchInput.searchFor(fileJpgUser1.name);
await searchInput.searchFor(`${fileJpgUser1.name}*`);
await dataTable.waitForBody();

const expectedUsers2 = [`${user1} ${user1}`];
Expand Down
66 changes: 63 additions & 3 deletions projects/aca-content/assets/app.extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1532,7 +1532,27 @@
"visible": "app.areCategoriesEnabled"
}
}
]
],
"highlight": {
"prefix": "<span class='aca-highlight'>",
DenysVuika marked this conversation as resolved.
Show resolved Hide resolved
"postfix": "</span>",
"fields": [
{
"field": "cm:title"
},
{
"field": "cm:name"
},
{
"field": "cm:description",
"snippetCount": 1
},
{
"field": "cm:content",
"snippetCount": 1
}
]
}
},
{
"id": "app.search.dublin-core",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
]
}
}

],
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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'>";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not we get this from the config?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could but then the question comes which filter set we should base on, also there might be new ones added by the customers so this wouldn't be very reliable solution as well

MichalKinas marked this conversation as resolved.
Show resolved Hide resolved
private readonly highlightPostfix = '</span>';

private node: NodeEntry;
private onDestroy$ = new Subject<boolean>();

Expand All @@ -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;

Expand Down Expand Up @@ -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'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be good idea to update model to have search and highlight fields defined inside models? Thanks that we won't need to do some workarounds with [] and IDE will hint available fields.

Not sure if that is easy to update and if that is possible so if not in that PR we can also raise task for that. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically ResultSetRowEntry contains search properties but the component itself needs regular node to pass it to subsequent methods accepting node only, and node itself shouldn't contain the search properties imo @DenysVuika what do you think?

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);
}
}

Expand All @@ -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'), '')
AleksanderSklorz marked this conversation as resolved.
Show resolved Hide resolved
: '';
}
}
Loading
Loading