@legumeinfo/web-components
Version:
Web Components for the Legume Information System and other AgBio databases
514 lines (511 loc) • 22.6 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { LisCancelPromiseController, LisDomContentLoadedController, LisQueryStringParametersController, } from '../controllers';
/**
* A mixin that encapsulates code that implements a paginated search. The mixin
* is a function that uses the factory pattern to generate a class to be
* extended by a component. To use the mixin, call the function with the
* appropriate template arguments and extend the class it returns when defining
* a component.
*
* @typeParam T - The class to use as the super class of the generated mixin
* class. Should be an instance of the `LitElement` class or a descendant of it.
* @typeParam SearchData - The type of data that will be given to
* {@link LisPaginatedSearchElementInterface.searchFunction | `searchFunction`}.
* @typeParam SearchResult - The type that is expected to be in the results
* array of the {@link PaginatedSearchResults | `PaginatedSearchResults`}
* instance resolved by the {@link !Promise | `Promise`} returned by the
* {@link LisPaginatedSearchElementInterface.searchFunction | `searchFunction`}.
*
* @param superClass - The class to use as the super class of the generated
* mixin class. Should be an instance of the `LitElement` class or a descendant
* of it.
*
* @returns The generated mixin class.
*
* @example
* When using the mixin, the
* {@link LisPaginatedSearchElementInterface.requiredQueryStringParams | `requiredQueryStringParams`},
y {@link LisPaginatedSearchElementInterface.resultAttributes | `resultAttributes`},
* and {@link LisPaginatedSearchElementInterface.tableHeader | `tableHeader`}
* properties of the extended class must be set in the component's constructor.
*
* The {@link LisPaginatedSearchElementInterface.renderForm | `renderForm`}
* method must be overridden to define the form part of the component's
* template. It is recommended that the form's elements' values are bound to the
* URL query string parameters using the inherited
* {@link LisPaginatedSearchElementInterface.queryStringController | `queryStringController`}
* since their values will automatically be reflected in the URL query string
* parameters.
*
* Lastly, note the due to TypeScript's lack of support for partial type
* argument inference the mixin function is curried. This means the function
* returns another function that must also be called to generate the mixin
* class:
* ```js
* @customElement('lis-gene-search-element')
* export class LisGeneSearchElement extends
* LisPaginatedSearchMixin(LitElement)<GeneSearchData, GeneSearchResult>() // <-- curried function call
* {
*
* // set properties in the constructor
* constructor() {
* super();
* // configure query string parameters
* this.requiredQueryStringParams = [['query']];
* // configure results table
* this.resultAttributes = ['name', 'description'];
* this.tableHeader = {name: 'Name', description: 'Description'};
* }
*
* // define the form part of the template
* override renderForm() {
* // NOTE:
* // 1) the input element has a name attribute, which all form elemnts are required to have
* // 2) the input value is set to a URL query string parameter value
* return html`
* <form>
* <fieldset class="uk-fieldset">
* <legend class="uk-legend">Gene search</legend>
* <div class="uk-margin">
* <input
* name="query" // <-- all form elements need a name
* class="uk-input"
* type="text"
* placeholder="Input"
* aria-label="Input"
* .value=${this.queryStringController.getParameter('query')}>
* </div>
* <div class="uk-margin">
* <button type="submit" class="uk-button uk-button-primary">Search</button>
* </div>
* </fieldset>
* </form>
* `;
* }
*
* }
* ```
*
* @example
* By default, the {@link LisPaginatedSearchMixin | `LisPaginatedSearchMixin`} renders
* search results using the {@link LisSimpleTableElement | `LisSimpleTableElement`}.
* If this is too restrictive, a class that uses the mixin may override its
* `renderResults` method to draw the results portion of the template itself.
* For example:
* ```js
* @customElement('lis-gene-search-element')
* export class LisGeneSearchElement extends
* LisPaginatedSearchMixin(LitElement)<GeneSearchData, GeneSearchResult>() // <-- curried function call
* {
*
* // set properties in the constructor
* constructor() {
* super();
* // configure query string parameters
* this.requiredQueryStringParams = [['query']];
* // no need to configure the results table since we're going to override it
* }
*
* // define the form part of the template
* override renderForm() {
* ...
* }
*
* // define the results part of the template
* override renderResults() {
* // this is actually the default implementation provided by the mixin
* return html`
* <lis-simple-table-element
* caption="Search Results"
* .dataAttributes=${this.resultAttributes}
* .header=${this.tableHeader}
* .data=${this.searchResults}>
* </lis-simple-table-element>
* `;
* }
*
* }
* ```
*/
// HACK: curried function because TypeScript doesn't support partial type
// argument inference: https://github.com/microsoft/TypeScript/issues/26242
export const LisPaginatedSearchMixin = (superClass) => () => {
// the mixin class
class LisPaginatedSearchElement extends superClass {
/////////////////
// constructor //
/////////////////
constructor(...rest) {
super(...rest);
/////////////////
// controllers //
/////////////////
// a controller for interacting with URL query string parameters
this.queryStringController = new LisQueryStringParametersController(this);
// a controller for adding a DOM Content Loaded event listener
this.domContentLoadedController = new LisDomContentLoadedController(this);
// a controller that allows in-flight seaches to be cancelled
this.cancelPromiseController = new LisCancelPromiseController(this);
//////////////////////////
// properties and state //
//////////////////////////
// the search callback function; not an attribute because functions can't be
// parsed from attributes
this.searchFunction = () => Promise.reject(new Error('No search function provided'));
// attributes of result objects in the concrete class
this.resultAttributes = [];
// the table headers to use in the concrete class
this.tableHeader = {};
// the table column classes to use in the concrete class
this.tableColumnClasses = {};
// what form parts are required to submit a search
this.requiredQueryStringParams = [];
this.queryStringReflection = true;
// keep a copy of the search results for template generalization
this.searchResults = [];
// info about the search results
this.resultsInfo = '';
// keep a copy of the search form data for pagination
this._searchData = undefined;
// bind to the form (wrapper) element in the template
this._formRef = createRef();
// bind to the loading element in the template
this._loadingRef = createRef();
// bind to a download element
this._downloadingRef = createRef();
// was the form submitted via querystring parameter change
// TODO: is there a better way to handle this state?
this._queryStringSubmitted = false;
}
/////////////////////
// lifecycle hooks /
/////////////////////
connectedCallback() {
super.connectedCallback();
if (this.queryStringReflection) {
// submit the form after the DOM is finished loading
this.domContentLoadedController.addListener(this._queryStringSubmit);
// submit the form whenever the query string parameters change
this.queryStringController.addListener(this._queryStringSubmit);
}
}
////////////
// styles //
////////////
// disable shadow DOM to inherit global styles
createRenderRoot() {
return this;
}
////////////////////
// helper methods //
////////////////////
valueOrQuerystringParameter(value, parameter) {
if (value === undefined) {
return this.queryStringController.getParameter(parameter);
}
return value;
}
////////////////////
// search methods //
////////////////////
// allows the form in the paginated search element to be submitted programmatically
submit() {
var _a;
// throw an error if the form wrapper is missing
if (this._formRef.value === undefined) {
throw new Error('No form wrapper in the template');
}
// submit the form via the form wrapper
(_a = this._formRef.value) === null || _a === void 0 ? void 0 : _a.submit();
}
// submits the form if it was populated from querystring parameters
_queryStringSubmit() {
// submit the form if one or more groups of parameters are present
const hasFields = this.requiredQueryStringParams.some((group) => {
// check that every parameter in the group is in the querystring
return (group.length &&
group.every((field) => {
return Boolean(this.queryStringController.getParameter(field));
}));
});
if (hasFields && this.requiredQueryStringParams.length) {
this._paginator.page = Number(this.queryStringController.getParameter('page', '1'));
this._queryStringSubmitted = true;
// submit the form for validation
this.submit();
}
else {
this._resetComponent();
}
}
// called when the page changes
_changePage(e) {
e.preventDefault();
e.stopPropagation(); // we'll emit our own event
if (this._searchData !== undefined) {
const page = this._paginator.page;
this._searchData = { ...this._searchData, page };
}
// can't change the page unless there's already been a successful search;
// skip form submit and use previous search data
this._search(this._searchData);
}
// performs a search via an external function
_search(searchData) {
var _a;
if (searchData === undefined) {
return;
}
(_a = this._loadingRef.value) === null || _a === void 0 ? void 0 : _a.loading();
if (!this._queryStringSubmitted && this.queryStringReflection) {
this.queryStringController.setParameters(searchData);
}
else {
this._queryStringSubmitted = false;
}
this.cancelPromiseController.cancel();
const options = {
abortSignal: this.cancelPromiseController.abortSignal,
};
const searchPromise = this.searchFunction(searchData, options);
this.cancelPromiseController.wrapPromise(searchPromise).then((results) => this._searchSuccess(results), (error) => {
var _a;
// do nothing if the request was aborted
if (!(error instanceof Event && error.type === 'abort')) {
(_a = this._loadingRef.value) === null || _a === void 0 ? void 0 : _a.failure();
throw error;
}
});
}
// updates the table and loading element with the search result data
_searchSuccess(paginatedResults) {
var _a, _b, _c;
// reset the initial page
// destruct the paginated search result
const { hasNext, numPages, results, errors } = {
// provide a default value for hasNext based on if there's any results
hasNext: Boolean(paginatedResults.results.length),
...paginatedResults,
};
// update the loading element accordingly
if (errors && errors.length) {
const message = errors.join('<br/>');
(_a = this._loadingRef.value) === null || _a === void 0 ? void 0 : _a.error(message);
}
else if (results.length) {
(_b = this._loadingRef.value) === null || _b === void 0 ? void 0 : _b.success();
}
else {
(_c = this._loadingRef.value) === null || _c === void 0 ? void 0 : _c.noResults();
}
// display the results in the table
this.resultsInfo = this._getResultsInfo(paginatedResults);
this.searchResults = results;
// update the pagination element
this._paginator.hasNext = hasNext;
this._paginator.numPages = numPages;
}
// performs a search via an external function
_download(formData) {
var _a;
if (this.downloadFunction !== undefined) {
// show the downloading element
(_a = this._downloadingRef.value) === null || _a === void 0 ? void 0 : _a.loading();
this.cancelPromiseController.cancel();
const options = {
abortSignal: this.cancelPromiseController.abortSignal,
};
// NOTE: an explicit cast is done here because the type inference is wrong
const downloadPromise = this.downloadFunction(formData, options);
this.cancelPromiseController.wrapPromise(downloadPromise).then((results) => this._downloadSuccess(results), (error) => {
var _a;
// do nothing if the request was aborted
if (!(error instanceof Event && error.type === 'abort')) {
(_a = this._downloadingRef.value) === null || _a === void 0 ? void 0 : _a.failure();
throw error;
}
});
}
}
// updates the table and loading element with the search result data
_downloadSuccess(downloadResults) {
var _a, _b;
const { errors } = downloadResults;
// update the loading element accordingly
if (errors && errors.length) {
const message = errors.join('<br/>');
(_a = this._downloadingRef.value) === null || _a === void 0 ? void 0 : _a.error(message);
}
else {
(_b = this._downloadingRef.value) === null || _b === void 0 ? void 0 : _b.success();
}
}
//////////////////////////
// update state methods //
//////////////////////////
// converts the given FormData instance into an Object that will be passed to
// the searchFunction
// this is a default implementation and should be overridden in the concrete class
// class if any ambiguity in the FormData needs to be resolved
formToObject(formData) {
return Object.fromEntries(formData);
}
// resets the component to its initial state
_resetComponent() {
var _a;
// update the search data
this._searchData = undefined;
// update the loading element
(_a = this._loadingRef.value) === null || _a === void 0 ? void 0 : _a.success();
// update the results
this.resultsInfo = '';
this.searchResults = [];
// update the pagination element
this._paginator.page = 1;
this._paginator.numPages = undefined;
this._paginator.hasNext = false;
}
// called when a search term is submitted
_formSubmitted(e) {
e.preventDefault();
e.stopPropagation(); // we'll emit our own event
const eventSubmitter = e.detail.formEvent.submitter;
const formData = this.formToObject(e.detail.formData);
if (eventSubmitter.value === 'download' &&
this.downloadFunction !== undefined) {
this._download(formData);
}
else {
// reset the paginator if this isn't a querystring search
if (!this._queryStringSubmitted || !this.queryStringReflection) {
this._paginator.page = 1;
}
// update the page before searching
const page = this._paginator.page;
this._searchData = { ...formData, page };
this._search(this._searchData);
}
}
// returns a string describing the results found by the search
_getResultsInfo(paginatedResults) {
// destruct the paginated search result
const { pageSize, numResults: totalResults, results } = paginatedResults;
const numResults = results.length;
const page = this._paginator.page;
// report the results range the page covers
const counts = [];
if (numResults > 0 && pageSize != undefined) {
const start = (page - 1) * pageSize + 1;
const end = start + numResults - 1;
const resultRange = `${start.toLocaleString()}-${end.toLocaleString()}`;
counts.push(resultRange);
}
// report the number of results total
if (totalResults !== undefined) {
if (counts.length > 0) {
counts.push('of');
}
const plural = totalResults == 1 ? '' : 's';
const resultsCount = `${totalResults.toLocaleString()} result${plural}`;
counts.push(resultsCount);
}
return counts.join(' ');
}
////////////////////
// render methods //
////////////////////
// a method the concrete class must implement to render the form
renderForm() {
throw new Error('Method not implemented');
}
// a method that provides a default template for displaying results info that can be
// overridden by the concrete class
renderResultsInfo() {
if (this.resultsInfo) {
return html `<p>${this.resultsInfo}</p>`;
}
return html ``;
}
// a method that provides a default template for displaying results that can be
// overridden by the concrete class
renderResults() {
return html `
<lis-simple-table-element
.dataAttributes=${this.resultAttributes}
.header=${this.tableHeader}
.columnClasses=${this.tableColumnClasses}
.data=${this.searchResults}
>
</lis-simple-table-element>
`;
}
render() {
// render the template parts
const form = this.renderForm();
const resultsInfo = this.renderResultsInfo();
const results = this.renderResults();
// the template
return html `
<lis-form-wrapper-element
${ref(this._formRef)}
="${this._formSubmitted}"
>
${form}
</lis-form-wrapper-element>
${resultsInfo}
<div class="uk-inline uk-width-1-1 uk-overflow-auto uk-text-small">
<lis-loading-element ${ref(this._loadingRef)}></lis-loading-element>
${results}
</div>
<lis-pagination-element
.scrollTarget=${this._formRef.value}
=${this._changePage}
>
</lis-pagination-element>
`;
}
}
__decorate([
property({ type: Function, attribute: false })
], LisPaginatedSearchElement.prototype, "searchFunction", void 0);
__decorate([
property({ type: Function, attribute: false })
], LisPaginatedSearchElement.prototype, "downloadFunction", void 0);
__decorate([
property()
], LisPaginatedSearchElement.prototype, "resultAttributes", void 0);
__decorate([
property()
], LisPaginatedSearchElement.prototype, "tableHeader", void 0);
__decorate([
property()
], LisPaginatedSearchElement.prototype, "tableColumnClasses", void 0);
__decorate([
state()
], LisPaginatedSearchElement.prototype, "requiredQueryStringParams", void 0);
__decorate([
state()
], LisPaginatedSearchElement.prototype, "queryStringReflection", void 0);
__decorate([
state()
], LisPaginatedSearchElement.prototype, "searchResults", void 0);
__decorate([
state()
], LisPaginatedSearchElement.prototype, "resultsInfo", void 0);
__decorate([
state()
], LisPaginatedSearchElement.prototype, "_searchData", void 0);
__decorate([
query('lis-pagination-element')
], LisPaginatedSearchElement.prototype, "_paginator", void 0);
return LisPaginatedSearchElement;
};
//# sourceMappingURL=lis-paginated-search-mixin.js.map