diff --git a/package.json b/package.json index 4b7ab97d0..fd48a1d60 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "bootstrap-3-typeahead": "^4.0.2", "bootstrap-colorpicker": "2.3.6", "bootstrap-select": "1.14.0-beta3", - "bootstrap-table": "1.21.2", + "bootstrap-table": "1.22.6", "bootstrap-treeview": "git@github.com:jonmiles/bootstrap-treeview.git#develop", "bootstrap-validator": "~0.11.9", "clipboard": "^2.0.6", diff --git a/src/core/clients/opencga/api/Workflow.js b/src/core/clients/opencga/api/Workflow.js index b1c93077a..7d6edc607 100644 --- a/src/core/clients/opencga/api/Workflow.js +++ b/src/core/clients/opencga/api/Workflow.js @@ -88,6 +88,16 @@ export default class Workflow extends OpenCGAParentClass { return this._get("workflows", null, null, null, "distinct", {field, ...params}); } + /** Execute a Nextflow analysis. + * @param {Object} data - Repository parameters. + * @param {Object} [params] - The Object containing the following optional parameters: + * @param {String} [params.study] - Study [[organization@]project:]study where study and project can be either the ID or UUID. + * @returns {Promise} Promise object in the form of RestResponse instance. + */ + importWorkflow(data, params) { + return this._post("workflows", null, null, null, "import", data, params); + } + /** Execute a Nextflow analysis. * @param {Object} data - NextFlow run parameters. * @param {Object} [params] - The Object containing the following optional parameters: diff --git a/src/sites/iva/index.html b/src/sites/iva/index.html index a24aea94b..b9289a309 100644 --- a/src/sites/iva/index.html +++ b/src/sites/iva/index.html @@ -136,7 +136,7 @@ - + diff --git a/src/webcomponents/commons/analysis/analysis-utils.js b/src/webcomponents/commons/analysis/analysis-utils.js index d5f1e3a1b..cd9514922 100644 --- a/src/webcomponents/commons/analysis/analysis-utils.js +++ b/src/webcomponents/commons/analysis/analysis-utils.js @@ -139,6 +139,9 @@ export default class AnalysisUtils { ...paramSections, { title: "Job Info", + display: { + className: "p-2" + }, elements: [ { title: "Job ID", diff --git a/src/webcomponents/commons/data-list.js b/src/webcomponents/commons/data-list.js new file mode 100644 index 000000000..d759efdce --- /dev/null +++ b/src/webcomponents/commons/data-list.js @@ -0,0 +1,492 @@ +/* + * Copyright 2015-2016 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 {html, LitElement, nothing} from "lit"; +import GridCommons from "./grid-commons.js"; +import UtilsNew from "../../core/utils-new.js"; + +export default class DataList extends LitElement { + + static LIST_MODE = "LIST"; + static GRID_MODE = "GRID"; + + constructor() { + super(); + + this.#init(); + } + + createRenderRoot() { + return this; + } + + static get properties() { + return { + data: { + type: Array + }, + search: { + type: String + }, + mode: { + type: String + }, + active: { + type: Boolean + }, + config: { + type: Object + } + }; + } + + #init() { + this._prefix = UtilsNew.randomString(8); + this._data = []; + this.data = []; + this.mode = DataList.LIST_MODE; + this.active = true; + this.htmlTableId = this._prefix + "TableHtmlId"; + + this.searchField = ""; + this.sortById = "none"; + + this.groupById = "none"; + this.groupByResult = {}; + this.groupByResultValues = []; + + this._config = this.getDefaultConfig(); + } + + update(changedProperties) { + if (changedProperties.has("data")) { + this.dataObserver(); + } + if (changedProperties.has("mode") || + changedProperties.has("config")) { + this.propertyObserver(); + } + super.update(changedProperties); + } + + updated(changedProperties) { + // If the search property is changed, we must update the input field and we need the DOM to be ready + if (changedProperties.has("search")) { + this.querySelector("#" + this._prefix + "InputSearch").value = this.search; + this.onSearch({currentTarget: {value: this.search}}); + } + if (changedProperties.size > 0 && this.active) { + this.renderTable(); + } + } + + dataObserver() { + this._data = JSON.parse(JSON.stringify(this.data)); + } + + modeObserver(e, mode) { + this.mode = mode; + this.renderTable(); + } + + propertyObserver() { + // With each property change we must be updated config and create the columns again. No extra checks are needed. + this._config = { + ...this.getDefaultConfig(), + ...this.config, + search: { + ...this.getDefaultConfig().search, + ...this.config.search, + }, + sortBy: { + ...this.getDefaultConfig().sortBy, + ...this.config.sortBy, + }, + groupBy: { + ...this.getDefaultConfig().groupBy, + ...this.config.groupBy, + }, + table: { + ...this.getDefaultConfig().table, + ...this.config.table, + }, + grid: { + ...this.getDefaultConfig().grid, + ...this.config.grid, + }, + columns: this.config.columns + }; + + this.gridCommons = new GridCommons(this.htmlTableId, this, this._config); + this.renderTable(); + } + + onSearch(e) { + // Save the search value + this.searchField = e.currentTarget.value; + + // 1. Filter the data using the original data! + if (e.currentTarget.value === "none") { + this._data = JSON.parse(JSON.stringify(this.data)); + } else { + this._data = this.#search(this.data, e.currentTarget.value); + } + + // 2. If we are sorting by some field, we need to re-sort the data to reflect the new order + if (this.sortById !== "none") { + const sortByOption = this._config.sortBy.options.find(option => option.id === this.sortById); + this._data = this.#sortData(this._data, sortByOption); + } + + // 3. If we are grouping by some field, we need to re-group the data to reflect the new order + if (this.groupById !== "none") { + // Note: this calls to renderTable() again + this.onGroupBy({currentTarget: {value: this.groupById}}); + } else { + this.renderTable(); + } + } + + #search(data, value) { + return data.filter(item => { + for (const field of this._config.search.fields) { + if (this._config.search.ignoreCase) { + if (Array.isArray(item[field])) { + if (item[field].join().toLowerCase().includes(value.toLowerCase())) { + return true; + } + } else { + if (item[field]?.toLowerCase().includes(value.toLowerCase())) { + return true; + } + } + } else { + if (Array.isArray(item[field])) { + if (item[field]?.join()?.includes(value)) { + return true; + } + } else { + if (item[field]?.includes(value)) { + return true; + } + } + } + } + }); + } + + onSortBy(e) { + // Save the selected sort by field + this.sortById = e.currentTarget.value; + + if (e.currentTarget.value === "none") { + // Reset the data + this._data = JSON.parse(JSON.stringify(this.data)); + if (this.searchField) { + this._data = this.#search(this.data, this.searchField); + } + } else { + // Get the selected option + const sortByOption = this._config.sortBy.options.find(option => option.id === e.currentTarget.value); + // Set default order to 'asc' + if (!sortByOption.order) { + sortByOption.order = "asc"; + } + // Sort by 'id' field using the already filtered data, no need to filter again + this._data = this.#sortData(this._data, sortByOption); + } + + // If we are grouping by some field, we need to re-group the data to reflect the new order + if (this.groupById !== "none") { + // Note: this calls to renderTable() again + this.onGroupBy({currentTarget: {value: this.groupById}}); + } else { + this.renderTable(); + } + } + + onSortByClear() { + const dropdown = document.getElementById(this._prefix + "SortBy"); + dropdown.value = "none"; + this.onSortBy({currentTarget: dropdown}); + } + + #sortData(data, sortByOption) { + return data.sort((a, b) => { + if (a[sortByOption.id] > b[sortByOption.id]) { + return sortByOption.order === "asc" ? 1 : -1; + } else if (a[sortByOption.id] < b[sortByOption.id]) { + return sortByOption.order === "asc" ? -1 : 1; + } else { + return 0; + } + }); + } + + onGroupBy(e) { + // Save the selected groupBy field + this.groupById = e.currentTarget.value; + + if (e.currentTarget.value === "none") { + this.groupByResult = {}; + this.groupByResultValues = []; + } else { + const groupByOption = this._config.groupBy.options.find(option => option.id === e.currentTarget.value); + this.groupByResult = this._data.reduce((result, currentValue) => { + (result[currentValue[groupByOption.id]] = result[currentValue[groupByOption.id]] || []) + .push(currentValue); + return result; + }, {}); + this.groupByResultValues = groupByOption.values?.length > 0 ? groupByOption.values : Object.keys(this.groupByResult); + } + this.renderTable(); + } + + onGroupByClear() { + const dropdown = document.getElementById(this._prefix + "GroupBy"); + dropdown.value = "none"; + this.onGroupBy({currentTarget: dropdown}); + } + + renderToolbar() { + const float = this._config?.display?.float === "left" ? "float-start" : "float-end"; + return html` +
+ `; + } + + async renderTable() { + // This renders the GRID mode automatically, since the render() method calls to the JS function renderUngroupedAndGroupByWithGrid() + this.requestUpdate(); + await this.updateComplete; + + // LIST mode is implemented using Bootstrap Table, so we need to call to these methods + if (this.mode.toUpperCase() === DataList.LIST_MODE) { + if (this.groupByResultValues?.length === 0) { + this.renderUngroupedWithLists(); + } else { + this.renderGroupByWithLists(); + } + } + } + + renderUngroupedWithLists() { + this.table = $("#" + this.htmlTableId); + this.table.bootstrapTable("destroy"); + this.table.bootstrapTable({ + uniqueId: "id", + data: this._data, + columns: this._config.columns, + + // Add default configuration + ...this._config.table, + + detailView: this._config.detailView, + gridContext: this, + loadingTemplate: () => GridCommons.loadingFormatter(), + // onClickRow: (row, selectedElement) => this.gridCommons.onClickRow(row.id, row, selectedElement), + }); + + // Show/Hide table header + this.gridCommons.hideHeader(!this._config.showTableHeader || true); + } + + renderGroupByWithLists() { + for (const value of this.groupByResultValues) { + const valueHtmlTableId = this._prefix + value + "TableHtmlId"; + this.table = $("#" + valueHtmlTableId); + this.table.bootstrapTable("destroy"); + this.table.bootstrapTable({ + uniqueId: "id", + data: this.groupByResult[value], + columns: this._config.columns, + + // Add default configuration + ...this._config.table, + + detailView: this._config.detailView, + gridContext: this, + loadingTemplate: () => GridCommons.loadingFormatter(), + // onClickRow: (row, selectedElement) => this.gridCommons.onClickRow(row.id, row, selectedElement), + }); + + const header = this.querySelector(`#${valueHtmlTableId} thead`); + if (header) { + if (!this._config.showTableHeader) { + header.style.display = "none"; + // this.context.querySelector(`#${this.gridId} tbody tr:first-child`).style.borderTopWidth = "1px"; + } else { + header.style.display = ""; + } + } + } + } + + renderUngroupedAndGroupByWithGrid(value) { + // 1. Get the data for the selected groupBy value, if not provided, use the ungrouped data + const localData = value ? this.groupByResult[value] || [] : this._data; + + // 2. If there is no data, show a message + if (localData.length === 0) { + return html` +