Related search web component for PnP Modern Search
A web component for showing related search results within a PnP Modern Search result. At work, this was used within a pnp-panel
web component to show documents related to the original search result.
This is best added by creating PnP Modern Search extensibility library.
It allows using a number of tokens, like {searchTerms}
, {inputQueryText}
, {TenantUrl}
, {Hub.HubSiteId}
and {Today}
, and supports audience targeting.
<pnp-search
query="test*"
row-limit="10"
select-properties="Title,Path,Description"
sort-direction="1"
sort-property="Created"
use-audience-targeting="true"
>
<template>
<ul>
{{#each results}}
<li>
<a href="{{Path}}">{{Title}}</a>
{{#if Description}}
<p>{{Description}}</p>
{{/if}}
</li>
{{/each}}
</ul>
</template>
</pnp-search>
Since the inner template uses Handlebars, if the web component is to be used within a Handlebars component, the inner template needs to be escaped using {{{{raw}}}}
and {{{{/raw}}}}
.
import { SPHttpClient } from '@microsoft/sp-http';
import { PageContext } from '@microsoft/sp-page-context';
import { BaseWebComponent } from '@pnp/modern-search-extensibility';
/**
* A web component that can display its own search results using Handlebars.
*/
export class SearchWebComponent extends BaseWebComponent {
private readonly DEFAULT_ROW_LIMIT = 10;
private readonly DEFAULT_SELECTED_PROPERTIES = ['Title', 'Path', 'Description'];
private readonly DEFAULT_SOURCE_ID = '8413cd39-2156-4e00-b54d-11efd9abdb89';
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
public async connectedCallback() {
await this.render();
}
private async render() {
const QueryTemplate = this.getAttribute('query');
const RowLimit = parseInt(this.getAttribute('row-limit') ?? this.DEFAULT_ROW_LIMIT.toString());
const SelectProperties =
this.getAttribute('select-properties')?.split(',') ?? this.DEFAULT_SELECTED_PROPERTIES;
const SourceId = this.getAttribute('source-id') ?? this.DEFAULT_SOURCE_ID;
const SortDirection = this.getAttribute('sort-direction') ?? undefined;
const SortProperty = this.getAttribute('sort-property') ?? undefined;
const UseAudienceTargeting = this.getAttribute('use-audience-target') === 'true';
const templateElement = this.querySelector('template');
if (!QueryTemplate || !templateElement || !this.shadowRoot) {
return;
}
const hbs = templateElement.innerHTML.replaceAll('\\{', '{');
const Handlebars = await import(
/* webpackChunkName: 'search-extensibility-handlebars' */ 'handlebars'
);
const template = Handlebars.compile(hbs);
const results = await this.fetchSearchResults({
QueryTemplate,
RowLimit,
SelectProperties,
SourceId,
SortDirection,
SortProperty,
UseAudienceTargeting,
});
const renderedHtml = template({ results });
this.shadowRoot.innerHTML = renderedHtml;
}
private async fetchSearchResults({
QueryTemplate,
RowLimit,
SelectProperties,
SourceId,
SortDirection,
SortProperty,
UseAudienceTargeting = false,
}: {
QueryTemplate: string;
RowLimit: number;
SelectProperties: string[];
SourceId: string;
SortDirection?: string;
SortProperty?: string;
UseAudienceTargeting?: boolean;
}) {
const spHttpClient = this._serviceScope.consume(SPHttpClient.serviceKey);
const pageContext = this._serviceScope.consume(PageContext.serviceKey);
const body: any = {
request: {
QueryTemplate,
ClientType: 'SearchWebComponent',
TrimDuplicates: false,
SelectProperties: {
results: SelectProperties,
},
RowLimit,
SourceId,
SortList: undefined,
},
};
if (SortDirection && SortProperty) {
body.request.SortList = {
results: [{ Direction: SortDirection, Property: SortProperty }],
};
}
if (UseAudienceTargeting) {
body.request.QueryTemplate +=
' AND (ModernAudienceAadObjectIds:{User.Audiences} OR NOT IsAudienceTargeted:true)';
}
body.request.QueryTemplate = await this.replaceTokens(body.request.QueryTemplate);
const response = await spHttpClient.post(
`${pageContext.web.serverRelativeUrl}/_api/search/postquery`,
SPHttpClient.configurations.v1,
{
headers: {
'odata-version': '3.0',
accept: 'application/json;odata=nometadata',
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
const data = await response.json();
return data.PrimaryQueryResult.RelevantResults.Table.Rows.map(this.transformResults);
}
private transformResults(row: any) {
const result: any = {};
row.Cells.forEach((cell: any) => {
result[cell.Key] = cell.Value;
});
return result;
}
/**
* @see https://microsoft-search.github.io/pnp-modern-search/usage/search-results/tokens/
* @see https://github.com/microsoft-search/pnp-modern-search/blob/main/search-parts/src/services/tokenService/TokenService.ts
* @param queryTemplate
* @returns
*/
private async replaceTokens(queryTemplate: string) {
const pageContext = this._serviceScope.consume(PageContext.serviceKey);
// Remove search terms, which would be common if copying directly from a search results web part
queryTemplate = queryTemplate.replace(/\{searchTerms\}/gi, '');
queryTemplate = queryTemplate.replace(/\{inputQueryText\}/gi, '');
queryTemplate = queryTemplate.replace(
/\{TenantUrl\}\/?/gi,
pageContext.legacyPageContext.portalUrl,
);
// Current hub
queryTemplate = queryTemplate.replace(
/\{Hub.HubSiteId\}/gi,
pageContext.legacyPageContext.hubSiteId,
);
// Current date
queryTemplate = queryTemplate.replace(/\{Today\}/gi, new Date().toISOString().split('T')[0]);
// Replace manually escaped curly braces
queryTemplate = queryTemplate.replace(/\\({|})/gi, '$1');
return queryTemplate;
}
}