UNPKG

@legumeinfo/web-components

Version:

Web Components for the Legume Information System and other AgBio databases

514 lines (511 loc) 22.6 kB
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)} @submit="${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} @pageChange=${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