diff --git a/cypress/e2e/iva/individual-browser-grid.cy.js b/cypress/e2e/iva/individual-browser-grid.cy.js index 69b9a267aa..ff21ff3cf1 100644 --- a/cypress/e2e/iva/individual-browser-grid.cy.js +++ b/cypress/e2e/iva/individual-browser-grid.cy.js @@ -111,6 +111,49 @@ context("Individual Browser Grid", () => { }); }); + // MODAL CREATE AUTOCOMPLETE + context("Modal Create Autocomplete", () => { + beforeEach(() => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get("@container") + .find(`button[data-action="create"]`) + .click(); + cy.get("@container") + .find(`div[data-cy="modal-create"]`) + .as("modal-create"); + }); + + it("should autocomplete on searching and selecting one result", () => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get("@modal-create") + .contains("ul.nav.nav-tabs > li", "Phenotypes") + .click(); + cy.get("@modal-create") + .contains("button", "Add Item") + .click(); + cy.get("cellbase-search-autocomplete") + .find("select-token-filter .select2-container") + .click(); + cy.get("cellbase-search-autocomplete") + .find("input.select2-search__field") + .type("gli"); + cy.get("cellbase-search-autocomplete") + .find("span.select2-results li") + .first() + .click(); + cy.get("cellbase-search-autocomplete") + .find("span.select2-selection__rendered") + .should("contain.text", "Glioblastoma multiforme"); + cy.get("@modal-create") + .find(`input[placeholder="Add phenotype ID......"]`) + .then(element => { + expect(element.val()).equal("HP:0012174"); + cy.wrap(element).should("be.disabled"); + }); + + }); + }); + // MODAL UPDATE context("Modal Update", () => { beforeEach(() => { @@ -352,7 +395,6 @@ context("Individual Browser Grid", () => { .find(`tbody tr[data-index="0"]`) .as("row"); }); - }); // 4. Extensions @@ -442,7 +484,7 @@ context("Individual Browser Grid", () => { .find(`td`) .eq(1) .trigger("click"); - + cy.get(`detail-tabs h3`) .should("contain.text", `Individual ${individual}`); }); @@ -452,7 +494,7 @@ context("Individual Browser Grid", () => { .find("li") .contains("JSON Data") .trigger("click"); - + cy.get("json-viewer") .should("be.visible"); }); diff --git a/src/core/clients/cellbase/cellbase-client.js b/src/core/clients/cellbase/cellbase-client.js index 66b1aa26c3..84337d9406 100644 --- a/src/core/clients/cellbase/cellbase-client.js +++ b/src/core/clients/cellbase/cellbase-client.js @@ -269,6 +269,7 @@ export class CellBaseClient { const url = this._createRestUrl(host, version, species, category, subcategory, ids, resource, params); const k = this.generateKey({...params, species, category, subcategory, resource, params}); + // FIXME: add try-catch-finally (see opencga-rest-input.js) return this.restClient.call(url, options, k); } diff --git a/src/sites/test-app/clients/cellbase-client-mock.js b/src/sites/test-app/clients/cellbase-client-mock.js index 40f559cff8..b7a9c7cac8 100644 --- a/src/sites/test-app/clients/cellbase-client-mock.js +++ b/src/sites/test-app/clients/cellbase-client-mock.js @@ -1,4 +1,5 @@ import UtilsNew from "../../../core/utils-new.js"; +import {RestResponse} from "../../../core/clients/rest-response"; export class CellBaseClientMock { @@ -42,6 +43,19 @@ export class CellBaseClientMock { return UtilsNew.importJSONFile(`./test-data/${this._config.testDataVersion}/genome-browser-region-17-43102293-43106467-variants.json`); } } + } + if (category === "feature") { + if (subcategory === "ontology" && resource === "search") { + switch (params.name) { + case "~/gli/i": + return UtilsNew.importJSONFile(`./test-data/${this._config.testDataVersion}/cellbase-autocomplete-phenotypes.json`) + .then(data => new RestResponse({responses: [{results: data}]})); + default: + break; + } + } + + } // Other request return Promise.reject(new Error("Not implemented")); diff --git a/src/webcomponents/commons/filters/cellbase-search-autocomplete.js b/src/webcomponents/commons/filters/cellbase-search-autocomplete.js new file mode 100644 index 0000000000..a56c358999 --- /dev/null +++ b/src/webcomponents/commons/filters/cellbase-search-autocomplete.js @@ -0,0 +1,275 @@ +/** + * Copyright 2015-2023 OpenCB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {LitElement, html} from "lit"; +import LitUtils from "../utils/lit-utils.js"; +import "../forms/select-token-filter.js"; +import UtilsNew from "../../../core/utils-new"; + +export default class CellbaseSearchAutocomplete extends LitElement { + + constructor() { + super(); + + this.#init(); + } + + createRenderRoot() { + return this; + } + + static get properties() { + return { + value: { + type: Object + }, + resource: { + type: String, + }, + cellbaseClient: { + type: Object, + }, + classes: { + type: String + }, + config: { + type: Object + }, + searchField: { + type: String, + }, + }; + } + + #init() { + this.RESOURCES = {}; + this.endpoint = {}; + this.defaultQueryParams = { + limit: 10, + count: false, + }; + this.#initResourcesConfig(); + this.searchField = ""; + } + + #initResourcesConfig() { + this.RESOURCES = { + "PHENOTYPE": { + category: "feature", + subcategory: "ontology", + operation: "search", + getSearchField: term => /^[^:\s]+:/.test(term) ? "id": "name", + placeholder: "Start typing a phenotype ID or name...", + queryParams: {}, + // Pre-process the results of the query if needed. + }, + "DISORDER": { + category: "feature", + subcategory: "ontology", + operation: "search", + getSearchField: term => /^[^:\s]+:/.test(term) ? "id": "name", + placeholder: "Start typing a disorder ID or name ...", + queryParams: {}, + // Pre-process the results of the query if needed. + }, + "GENE": { + // CAUTION: In feature-filter.js L71, we are autocompleting: xref, ids, gene, geneName. + category: "feature", + subcategory: "gene", + operation: "search", + getSearchField: term => { + // FIXME: Query gene by id is not working! Temporarily returning ALWAYS name + // return term.startsWith("ENSG0") ? "id" : "name"; + return term.startsWith("ENSG0") ? "name" : "name"; + }, + valueField: "name", + placeholder: "Start typing an ensemble gene ID or name...", + queryParams: { + exclude: "transcripts,annotation", + }, // CAUTION: query params depends on the resource/operation (i.e. search, info) used + }, + "VARIANT": {}, + "PROTEIN": {}, + "TRANSCRIPT": {}, + "VARIATION": {}, + "REGULATORY": {}, + }; + } + + // Filter the fields that can be used to fill the form automatically + #filterResults(results) { + return results.map(item => ({ + id: item.id, + name: item.name, + source: item.source, + description: item.description, + text: item.name || item.id, + })); + } + + // Templating one option of dropdown + // TODO Vero: Style with default bootstrap + #viewResultStyle() { + return html ` + + `; + } + + #viewResult(option) { + return option.name ? $(` +
+
+
${option.source}
+
${option.name}
+
+ +
+ `) : $(` + ${option.text} + `); + } + + update(changedProperties) { + if (changedProperties.has("resource")) { + this.resourceObserver(); + } + + if (changedProperties.has("config")) { + this.configObserver(); + } + + super.update(changedProperties); + } + + resourceObserver() { + if (this.resource) { + this.resource.toUpperCase(); + } + this._config = { + ...this.getDefaultConfig(), + ...this.config, + }; + } + + configObserver() { + this._config = { + ...this.getDefaultConfig(), + ...this.config + }; + } + + onFilterChange(e) { + const value = e.detail.value; + const data = e.detail.data.selected ? e.detail.data : {}; + if (!UtilsNew.isEmpty(data)) { + // 1. To remove internal keys from select2 that are not part of the data model. + const internalKeys = ["selected", "text"]; + internalKeys.forEach(key => delete data[key]); + // 2. To filter out entries with undefined values + Object.keys(data).forEach(key => typeof data[key] === "undefined" && delete data[key]); + } + // 3. To dispatch event with value autocompleted and data filtered + LitUtils.dispatchCustomEvent(this, "filterChange", value, { + data: data, + }); + } + + render() { + if (!this.resource) { + return html`Resource not provided`; + } + + return html` + + + `; + } + + getDefaultConfig() { + return { + disabled: false, + multiple: false, + freeTag: false, + limit: 10, + maxItems: 0, // No limit set + minimumInputLength: 3, // Only start searching when the user has input 3 or more characters + placeholder: this.RESOURCES[this.resource].placeholder, + filterResults: this.#filterResults, + viewResultStyle: this.#viewResultStyle, + viewResult: this.#viewResult, + viewSelection: result => result[this.searchField], + // TODO Vero: change name to fetch + source: async (params, success, failure) => { + const page = params?.data?.page || 1; + const queryParams = { + ...this.defaultQueryParams, + ...this.RESOURCES[this.resource].queryParams, + skip: (page - 1) * this._config.limit, + }; + if (params?.data?.term) { + // Get the query param field. It will vary with the text typed by the user according to a regex + this.searchField = this.RESOURCES[this.resource].getSearchField(params.data.term); + // Set the query params + const queryParamsField = { + [this.searchField]: `~/${params?.data?.term}/i`, + ...queryParams, + }; + // Query cellbase with the appropriate resource params + try { + const response = await this.cellbaseClient.get( + this.RESOURCES[this.resource].category, + this.RESOURCES[this.resource].subcategory, + "", + this.RESOURCES[this.resource].operation, + queryParamsField); + success(response); + } catch (error) { + // TODO Vero 20230928: manage failure + console.log(error); + } + } + }, + }; + } + +} + +customElements.define("cellbase-search-autocomplete", CellbaseSearchAutocomplete); diff --git a/src/webcomponents/commons/forms/data-form.js b/src/webcomponents/commons/forms/data-form.js index 963c5b5b7f..dca24bc8a7 100644 --- a/src/webcomponents/commons/forms/data-form.js +++ b/src/webcomponents/commons/forms/data-form.js @@ -83,6 +83,8 @@ export default class DataForm extends LitElement { // We need to initialise 'data' in case undefined value is passed this.data = {}; + // Maintains a data model of the data that has been filled out using the search autocomplete + this.dataAutocomplete = {}; } update(changedProperties) { @@ -1257,6 +1259,30 @@ export default class DataForm extends LitElement { _createObjectElement(element) { const isDisabled = this._getBooleanValue(element.display?.disabled, false, element); const contents = []; + + if (element.display?.search && typeof element.display.search === "object") { + // If 'field' is defined then we pass it to the 'render' function, otherwise 'data' object is passed + const data = this.data[element.field][element.index]; + const searchContent = html ` +
+ + ${element.display.title ? html` +
+ +
+ ` : null} + + +
+ ${element.display.search.render(data, object => this.onObjectChange(element, object, {action: "AUTOCOMPLETE"}))} +
+
+ `; + contents.push(searchContent); + } + for (const childElement of element.elements) { // 1. Check if this filed is visible const isVisible = this._getBooleanValue(childElement.display?.visible, true, childElement); @@ -1270,6 +1296,10 @@ export default class DataForm extends LitElement { nested: true }; + if (!UtilsNew.isEmpty(this.dataAutocomplete) && this._isFieldAutocomplete(childElement.field)) { + childElement.display.disabled = true; + } + // 2.1 If parent is disabled then we must overwrite disabled field if (isDisabled) { childElement.display.disabled = isDisabled; @@ -1350,6 +1380,10 @@ export default class DataForm extends LitElement { const _element = JSON.parse(JSON.stringify(element)); // We create 'virtual' element fields: phenotypes[].1.id, by doing this all existing // items have a virtual element associated, this will allow to get the proper value later. + if (_element.display?.search && typeof element.display?.search?.render === "function") { + _element.index = index; + _element.display.search.render = element.display.search.render; + } for (let i = 0; i< _element.elements.length; i++) { // This support object nested const [left, right] = _element.elements[i].field.split("[]."); @@ -1378,6 +1412,7 @@ export default class DataForm extends LitElement { } } return html` +
${element.display.view(item)} @@ -1399,6 +1434,7 @@ export default class DataForm extends LitElement { }
+
${this._createObjectElement(_element)} @@ -1460,7 +1496,7 @@ export default class DataForm extends LitElement { `: nothing } ${this._getBooleanValue(element.display.showResetListButton, false) ? html` -