import { css, CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import { SearchParamURLGenerator } from '../src/search-param-url-generator'; import { FilterConstraint, FilterMap, SearchParams, SortDirection, } from '../src/search-params'; import { SearchServiceInterface } from '../src/search-service-interface'; import { SearchResponse } from '../src/responses/search-response'; import { SearchResult } from '../src/models/hit-types/hit'; import { Aggregation, Bucket } from '../src/models/aggregation'; import { FilterMapBuilder } from '../src/filter-map-builder'; import { SearchType } from '../src/search-type'; type SingleFilter = { field: string; value: string; constraint: FilterConstraint; }; @customElement('search-query') export class SearchQuery extends LitElement { @property({ type: Object }) searchService?: SearchServiceInterface; @property({ type: Boolean }) debuggingEnabled = false; @query('#search-input') private searchInput!: HTMLInputElement; @query('#search-within-check') private searchWithinCheck!: HTMLInputElement; @query('#debug-info-check') private debugCheck!: HTMLInputElement; @query('#num-rows') private rowsInput!: HTMLInputElement; @query('#num-aggs') private numAggsInput!: HTMLInputElement; @query(`input[name='sort']:checked`) private checkedSort!: HTMLInputElement; @query('#filter-field') private filterFieldInput!: HTMLSelectElement; @query('#filter-constraint') private filterConstraintInput!: HTMLSelectElement; @query('#filter-value') private filterValueInput!: HTMLInputElement; @query('#aggs-default') private defaultAggregationsCheckbox!: HTMLInputElement; @state() private filterMap: FilterMap = {}; @state() private loadingSearchResults = false; @state() private loadingAggregations = false; @state() private lastSearchParams?: string; @state() private lastAggregationParams?: string; @state() private defaultAggregationsChecked = true; @state() private fullSearchResultsShown = false; @state() private searchResponse?: SearchResponse; @state() private aggregationsResponse?: SearchResponse; private get searchResults(): SearchResult[] | undefined { return this.searchResponse?.response.results; } private get searchAggregations(): Record | undefined { return this.aggregationsResponse?.response.aggregations; } render(): TemplateResult { return html`
Search options
Search type:
Search size:
Sort by title:
Filters:
${this.appliedFiltersTemplate}
Include aggregations for: ${this.aggregationCheckboxTemplate('mediatype', 'Mediatype')} ${this.aggregationCheckboxTemplate('year', 'Year')} ${this.aggregationCheckboxTemplate('subject', 'Subject')} ${this.aggregationCheckboxTemplate('language', 'Language')} ${this.aggregationCheckboxTemplate('creator', 'Creator')} ${this.aggregationCheckboxTemplate('collection', 'Collection')} ${this.aggregationCheckboxTemplate('lending___status', 'Lending')}
${this.searchResults || this.loadingSearchResults ? this.resultsTemplate : nothing} `; } private filterFieldChanged(e: Event) { const target = e.target as HTMLSelectElement; const fieldIsNumeric = !!target.selectedOptions[0].dataset.numeric; const constraints = (this.shadowRoot?.querySelectorAll( '#filter-constraint option' ) ?? []) as HTMLOptionElement[]; for (const constraint of constraints) { constraint.toggleAttribute( 'hidden', !fieldIsNumeric && !!constraint.dataset.numeric ); } } private addFilterClicked() { const filterField = this.filterFieldInput.selectedOptions[0].value; const filterValue = this.filterValueInput.value; const filterConstraint = this.filterConstraintInput.selectedOptions[0] .value as FilterConstraint; if (!filterField || !filterConstraint || !filterValue) { return; } this.filterMap = new FilterMapBuilder() .setFilterMap(this.filterMap) .addFilter(filterField, filterValue, filterConstraint) .build(); this.filterValueInput.value = ''; } private removeFilterClicked(e: Event) { const target = e.target as HTMLButtonElement; const { field, value, constraint } = target.dataset; if (field && value && constraint) { this.filterMap = new FilterMapBuilder() .setFilterMap(this.filterMap) .removeSingleFilter(field, value, constraint as FilterConstraint) .build(); } } private get appliedFiltersTemplate() { const filtersArray: SingleFilter[] = []; for (const [field, filters] of Object.entries(this.filterMap)) { for (const [value, constraint] of Object.entries(filters)) { // The constraint may be either a single item or an array if (Array.isArray(constraint)) { for (const subConstraint of constraint) { filtersArray.push({ field, value, constraint: subConstraint }); } } else { filtersArray.push({ field, value, constraint }); } } } if (filtersArray.length === 0) return html`(no filters applied)`; const readableConstraints: Record = { inc: 'includes', exc: 'excludes', gt: '>', gte: '>=', lt: '<', lte: '<=', }; return map(filtersArray, ({ field, value, constraint }) => { return html` '${field}' ${readableConstraints[constraint]} '${value}' `; }); } private aggregationCheckboxTemplate(value: string, label: string) { const id = `aggs-${value}`; return html` `; } private get resultsTemplate(): TemplateResult { return html`
PPS URL params ${this.lastSearchParams ? html`
Last search params:
${this.lastSearchParams}
` : nothing} ${this.lastAggregationParams ? html`
Last aggregation params:
${this.lastAggregationParams}
` : nothing}
${this.loadingSearchResults ? html`

Loading search results...

` : [this.minimalSearchResultsTemplate, this.fullSearchResultsTemplate]} ${this.loadingAggregations ? html`

Loading aggregations...

` : this.aggregationsTemplate} `; } private get minimalSearchResultsTemplate(): TemplateResult { return html`

Search Results

${this.snippetsHeaderTemplate} ${this.searchResults?.map(hit => { return html` ${this.snippetTemplate(hit)} `; })}
Identifier Title
${hit.identifier} ${hit.title?.value ?? '(Untitled)'}
`; } private get fullSearchResultsTemplate(): TemplateResult { return html` ${this.fullSearchResultsShown ? html`
            ${JSON.stringify(this.searchResults, null, 2)}
          
` : nothing} `; } private get aggregationsTemplate(): TemplateResult { return html`

Aggregations

${Object.entries(this.searchAggregations ?? {}).map(([key, agg]) => { return html`

${key}

${agg.buckets .map((bucket: number | Bucket) => { if (typeof bucket === 'number') { return bucket; } else { return `${bucket.key} (${bucket.doc_count})`; } }) .join(', ')}

`; })}
`; } private get snippetsHeaderTemplate(): TemplateResult { return this.searchResults?.some(hit => hit.highlight) ? html`Snippets` : html`${nothing}`; } private snippetTemplate(hit: SearchResult): TemplateResult { return hit.highlight ? html`${hit.highlight.value}` : html`${nothing}`; } private toggleDefaultAggregations() { this.defaultAggregationsChecked = this.defaultAggregationsCheckbox?.checked; } private toggleFullSearchResults() { this.fullSearchResultsShown = !this.fullSearchResultsShown; } /** * Conduct a full search (both hits and aggregations) */ private async search(e: Event): Promise { e.preventDefault(); const term = this.searchInput.value; const checkedSearchType = this.shadowRoot?.querySelector( `input[name='search-type']:checked` ) as HTMLInputElement; const searchType = checkedSearchType?.value === 'fts' ? SearchType.FULLTEXT : SearchType.METADATA; this.fetchSearchResults(term, searchType); this.fetchAggregations(term, searchType); } /** * Fetch the search hits */ private async fetchSearchResults(query: string, searchType: SearchType) { const sortParam = this.checkedSort?.value === 'none' ? undefined : [ { field: 'title', direction: this.checkedSort?.value as SortDirection, }, ]; const numRows = Number(this.rowsInput?.value); const includeDebugging = this.debugCheck?.checked; const searchParams: SearchParams = { query, rows: numRows, sort: sortParam, filters: this.filterMap, aggregations: { omit: true }, debugging: includeDebugging, uid: 'demo', }; if (this.searchWithinCheck?.checked) { searchParams.pageTarget = query; searchParams.pageType = 'collection_details'; } this.lastSearchParams = decodeURIComponent( SearchParamURLGenerator.generateURLSearchParams(searchParams).toString() ); this.loadingSearchResults = true; const result = await this.searchService?.search(searchParams, searchType); this.loadingSearchResults = false; if (result?.success) { this.searchResponse = result?.success; } else { alert(`Oh noes: ${result?.error?.message}`); console.error('Error searching', result?.error); } } /** * Fetch the search aggregations (facets) */ private async fetchAggregations(query: string, searchType: SearchType) { const checkedAggs = this.shadowRoot?.querySelectorAll( `input[name='aggs']:checked` ); const aggregations = { simpleParams: checkedAggs ? [...checkedAggs].map(elmt => (elmt as HTMLInputElement).value) : undefined, }; const numAggs = Number(this.numAggsInput?.value); const includeDebugging = this.debugCheck?.checked; const searchParams: SearchParams = { query, rows: 0, filters: this.filterMap, aggregationsSize: numAggs, debugging: includeDebugging, uid: 'demo', }; if (!this.defaultAggregationsChecked) { searchParams.aggregations = aggregations; } this.lastAggregationParams = decodeURIComponent( SearchParamURLGenerator.generateURLSearchParams(searchParams).toString() ); this.loadingAggregations = true; const result = await this.searchService?.search(searchParams, searchType); this.loadingAggregations = false; if (result?.success) { this.aggregationsResponse = result?.success; } else { alert(`Oh noes: ${result?.error?.message}`); console.error('Error searching', result?.error); } } static get styles(): CSSResult { return css` :host { font-size: 1.3rem; } .search-options { margin-top: 0.6rem; } .field-row { margin: 0.3rem 0; } fieldset { margin-bottom: 0.5rem; } #search-input { min-width: 220px; } #applied-filters { margin-top: 6px; } .filter { display: inline-block; margin-bottom: 3px; font-size: 1.1rem; font-family: sans-serif; } .filter-text { padding: 3px 3px 3px 6px; border-radius: 3px 0 0 3px; background: #ccc; } .remove-filter { all: unset; padding: 3px 6px; border-radius: 0 3px 3px 0; background: #ccc; cursor: pointer; } .remove-filter:hover { background: #999; } .remove-filter:active { background: #888; } .input-with-label { display: inline-flex; align-items: center; margin-right: 8px; } .params { white-space: pre-wrap; } `; } }