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` +
+
No data available
+
+ `; + } + + // 3. Calculate the number of columns and rows + const numColumns = this._config.grid?.display?.columns || 2; + const numRows = Math.ceil(localData.length / numColumns); + const columnWidth = 12 / numColumns; + + const htmlResult = []; + for (let i = 0; i < numRows; i ++) { + // 4. Prepare the row data + const row = []; + for (let j = 0; j < numColumns; j++) { + if (i * numColumns + j < localData.length) { + row.push(localData[i * numColumns + j]); + } + } + + // 5. Render the row + htmlResult.push(html` +
+ ${row.map(r => html` +
+
+ ${this._config.grid.render(r)} +
+
+ `)} +
+ `); + } + + return html` +
+ ${htmlResult} +
+ `; + } + + render() { + return html` + ${this.renderToolbar()} + + ${this.mode === DataList.LIST_MODE ? html` + + ${this.groupByResultValues?.length === 0 ? html` +
+
+
+ ` : html` + ${this.groupByResultValues?.map(value => html` +
+

${value}

+
+
+ `)} + `} + ` : nothing} + + ${this.mode === DataList.GRID_MODE ? html` + ${this.groupByResultValues?.length === 0 ? this.renderUngroupedAndGroupByWithGrid() : html` + ${this.groupByResultValues?.map(value => html` +
+

${value}

+ ${this.renderUngroupedAndGroupByWithGrid(value)} +
+ `)} + `} + ` : nothing} + `; + } + + getDefaultConfig() { + return { + showTableHeader: false, + display: { + float: "right" + }, + search: { + fields: ["id", "name", "description"], + ignoreCase: true + }, + table: { + classes: "table table-borderless", + theadClasses: "table-light", + buttonsClass: "light", + iconsPrefix: GridCommons.GRID_ICONS_PREFIX, + icons: GridCommons.GRID_ICONS, + pagination: false, + pageSize: 100, + pageList: [100], + detailView: false, + }, + + }; + } + +} + +customElements.define("data-list", DataList); diff --git a/src/webcomponents/commons/filters/catalog-search-autocomplete.js b/src/webcomponents/commons/filters/catalog-search-autocomplete.js index 1add3924a..bb31036da 100644 --- a/src/webcomponents/commons/filters/catalog-search-autocomplete.js +++ b/src/webcomponents/commons/filters/catalog-search-autocomplete.js @@ -208,12 +208,14 @@ export default class CatalogSearchAutocomplete extends LitElement { "WORKFLOW": { searchField: "id", placeholder: "Start typing", - client: this.opencgaSession.opencgaClient.workflows(), + // client: this.opencgaSession.opencgaClient.workflows(), + fetch: filters => this.opencgaSession.opencgaClient.workflows().search(filters), fields: item => ({ - "name": item.id + id: item.id, + name: item.name }), query: { - include: "id" + include: "id,name" } }, "DIRECTORY": { diff --git a/src/webcomponents/commons/grid-commons.js b/src/webcomponents/commons/grid-commons.js index 85d43d56a..638e98a38 100644 --- a/src/webcomponents/commons/grid-commons.js +++ b/src/webcomponents/commons/grid-commons.js @@ -400,4 +400,16 @@ export default class GridCommons { } } + hideHeader(hide = false) { + const header = this.context.querySelector(`#${this.gridId} thead`); + if (header) { + if (hide) { + header.style.display = "none"; + // this.context.querySelector(`#${this.gridId} tbody tr:first-child`).style.borderTopWidth = "1px"; + } else { + header.style.display = ""; + } + } + } + } diff --git a/src/webcomponents/commons/modal/modal-utils.js b/src/webcomponents/commons/modal/modal-utils.js index 191d5c43e..3058ed3f9 100644 --- a/src/webcomponents/commons/modal/modal-utils.js +++ b/src/webcomponents/commons/modal/modal-utils.js @@ -17,7 +17,7 @@ export default class ModalUtils { static create(self, id, config) { // Parse modal parameters, all of them must start with prefix 'modal' - const modalWidth = config.display?.modalWidth || "768px"; + const modalWidth = config.display?.modalWidth || "auto"; const modalSize = config.display?.modalSize || ""; const modalTitle = config.display?.modalTitle || ""; const modalTitleHeader = config.display?.modalTitleHeader || "h4"; diff --git a/src/webcomponents/workflow/analysis/workflow-analysis.js b/src/webcomponents/workflow/analysis/workflow-analysis.js index d7e04776e..e84ff1049 100644 --- a/src/webcomponents/workflow/analysis/workflow-analysis.js +++ b/src/webcomponents/workflow/analysis/workflow-analysis.js @@ -18,6 +18,8 @@ import {LitElement, html} from "lit"; import AnalysisUtils from "../../commons/analysis/analysis-utils.js"; import UtilsNew from "../../../core/utils-new.js"; import "../../commons/forms/data-form.js"; +import CatalogGridFormatter from "../../commons/catalog-grid-formatter"; +import NotificationUtils from "../../commons/utils/notification-utils"; export default class WorkflowAnalysis extends LitElement { @@ -37,6 +39,9 @@ export default class WorkflowAnalysis extends LitElement { toolParams: { type: Object, }, + search: { + type: Boolean, + }, opencgaSession: { type: Object, }, @@ -48,7 +53,7 @@ export default class WorkflowAnalysis extends LitElement { #init() { this.ANALYSIS_TOOL = "workflow"; - this.ANALYSIS_TITLE = "Workflow Analysis"; + this.ANALYSIS_TITLE = "Workflow Parameters"; this.ANALYSIS_DESCRIPTION = "Executes a workflow analysis job"; this.DEFAULT_TOOLPARAMS = {}; @@ -56,21 +61,30 @@ export default class WorkflowAnalysis extends LitElement { this.toolParams = { ...UtilsNew.objectClone(this.DEFAULT_TOOLPARAMS), }; + this.search = true; this.config = this.getDefaultConfig(); } update(changedProperties) { if (changedProperties.has("toolParams")) { - this.toolParams = { - ...UtilsNew.objectClone(this.DEFAULT_TOOLPARAMS), - ...this.toolParams, - }; - this.config = this.getDefaultConfig(); + this.toolParamsObserver(); } super.update(changedProperties); } + toolParamsObserver() { + this.toolParams = { + ...UtilsNew.objectClone(this.DEFAULT_TOOLPARAMS), + ...this.toolParams, + }; + this.config = this.getDefaultConfig(); + + if (this.toolParams.id) { + this.#fetchWorkflow(); + } + } + check() { // FIXME decide if this must be displayed // if (!this.toolParams.caseCohort) { @@ -79,22 +93,34 @@ export default class WorkflowAnalysis extends LitElement { // notificationType: "warning" // }; // } - return null; + return false; } + #fetchWorkflow() { + this.opencgaSession.opencgaClient.workflows() + .search({id: this.toolParams.id, study: this.opencgaSession.study.fqn}) + .then(restResponse => { + const results = restResponse.getResults(); + if (results.length > 0) { + this._workflow = results[0]; + } else { + console.error("Error in result format"); + } + }) + .catch(response => { + console.log(response); + }) + .finally(() => { + this.config = this.getDefaultConfig(); + this.requestUpdate(); + }); + } onFieldChange(e) { this.toolParams = {...this.toolParams}; - // Note: these parameters have been removed from the form - // Check if changed param was controlCohort --> reset controlCohortSamples field - // if (param === "controlCohort") { - // this.toolParams.controlCohortSamples = ""; - // } - // Check if changed param was caseCohort --> reset caseCohortSamples field - // if (param === "caseCohort") { - // this.toolParams.caseCohortSamples = ""; - // } - // this.config = this.getDefaultConfig(); - this.requestUpdate(); + + if (this.toolParams?.id) { + this.#fetchWorkflow(); + } } onSubmit() { @@ -147,9 +173,42 @@ export default class WorkflowAnalysis extends LitElement { } getDefaultConfig() { + // Create automatic form based on the workflow variables + const variables = []; + if (this._workflow?.variables?.length > 0) { + for (const variable of this._workflow.variables) { + const dataFormElement = { + title: variable.id, + field: variable.id, + required: variable.required || false, + display: { + defaultValue: variable.defaultValue, + help: { + text: `Variable name '${variable.name || variable.id}'. ${variable.description || ""}`, + } + } + }; + + switch (variable.type) { + case "BOOLEAN": + dataFormElement.type = "checkbox"; + break; + case "INTEGER": + case "DOUBLE": + case "STRING": + dataFormElement.type = "input-text"; + break; + } + variables.push(dataFormElement); + } + } + const params = [ { title: "Configuration", + display: { + className: "p-2" + }, elements: [ { title: "Workflow ID", @@ -162,7 +221,7 @@ export default class WorkflowAnalysis extends LitElement { .value="${caseCohort}" .resource="${"WORKFLOW"}" .opencgaSession="${this.opencgaSession}" - .config="${{multiple: false}}" + .config="${{multiple: false, disabled: !this.search}}" @filterChange="${e => dataFormFilterChange(e.detail.value)}"> `, @@ -174,6 +233,7 @@ export default class WorkflowAnalysis extends LitElement { type: "input-text", required: false, display: { + defaultValue: this._workflow?.version || "", help: { text: "Default version is the latest available", } @@ -183,7 +243,11 @@ export default class WorkflowAnalysis extends LitElement { }, { title: "Parameters", + display: { + className: "p-2" + }, elements: [ + ...variables, { title: "Parameters", field: "params", @@ -192,8 +256,22 @@ export default class WorkflowAnalysis extends LitElement { rows: 5, placeholder: "k1=v1\nk2=v2\nk3=v3", help: { - text: "Format valid is 'key=value', one per line. To use file you must use the prefix 'opencga://' before the path or name, for example: 'input_file=opencga://file.vcf'", - } + text: "Format valid is 'key=value', one per line. To use file you must use the prefix 'file://' before the path or name, for example: 'input_file=file://file.vcf'", + }, + visible: () => variables.length === 0 + } + }, + { + title: "Other Parameters", + field: "params", + type: "input-text", + display: { + rows: 5, + placeholder: "k1=v1\nk2=v2\nk3=v3", + help: { + text: "Format valid is 'key=value', one per line. To use file you must use the prefix 'file://' before the path or name, for example: 'input_file=file://file.vcf'. These parameters will override the ones defined above.", + }, + visible: () => variables.length > 0 } }, ] diff --git a/src/webcomponents/workflow/workflow-grid.js b/src/webcomponents/workflow/workflow-grid.js index 9b2e9ad1d..b216cf6a7 100644 --- a/src/webcomponents/workflow/workflow-grid.js +++ b/src/webcomponents/workflow/workflow-grid.js @@ -444,13 +444,13 @@ export default class WorkflowGrid extends LitElement {
  • - + Execute ...
  • - + Edit ...
  • @@ -528,6 +528,7 @@ export default class WorkflowGrid extends LitElement { render: () => html` `, diff --git a/src/webcomponents/workflow/workflow-import.js b/src/webcomponents/workflow/workflow-import.js new file mode 100644 index 000000000..2cc714b09 --- /dev/null +++ b/src/webcomponents/workflow/workflow-import.js @@ -0,0 +1,285 @@ +/** + * Copyright 2015-2022 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} from "lit"; +import LitUtils from "../commons/utils/lit-utils"; +import "../commons/filters/catalog-search-autocomplete.js"; +import GridCommons from "../commons/grid-commons"; +import NotificationUtils from "../commons/utils/notification-utils"; +import UtilsNew from "../../core/utils-new"; + +export default class WorkflowImport extends LitElement { + + constructor() { + super(); + + this.#init(); + } + + createRenderRoot() { + return this; + } + + static get properties() { + return { + opencgaSession: { + type: Object + }, + mode: { + type: String + }, + }; + } + + #init() { + this.workflow = {}; + this.mode = ""; + + this._config = this.getDefaultConfig(); + } + + #setLoading(value) { + this.isLoading = value; + this.requestUpdate(); + } + + update(changedProperties) { + if (changedProperties.has("opencgaSession")) { + this.fetchRepositories("nf-core"); + } + super.update(changedProperties); + } + + onAdd(e, row) { + const params = { + study: this.opencgaSession.study.fqn, + }; + let error; + this.#setLoading(true); + this.opencgaSession.opencgaClient.workflows() + .importWorkflow({id: row.full_name}, params) + .then(() => { + NotificationUtils.dispatch(this, NotificationUtils.NOTIFY_SUCCESS, { + title: "Workflow Import", + message: `New workflow ${row.full_name} imported correctly` + }); + }) + .catch(reason => { + error = reason; + NotificationUtils.dispatch(this, NotificationUtils.NOTIFY_RESPONSE, reason); + }) + .finally(() => { + LitUtils.dispatchCustomEvent(this, "workflowImport", {id: row.full_name}, {}, error); + this.#setLoading(false); + }); + } + + async fetchRepositories(org) { + const url = `https://api.github.com/orgs/${org}/repos`; + const headers = { + // 'Authorization': `token ${token}`, + "Accept": "application/vnd.github.v3+json" + }; + + this.repositories = []; + let page = 1; + + try { + while (true) { + const response = await fetch(`${url}?page=${page}&per_page=100`, {headers}); + if (!response?.ok) { + throw new Error(`Error fetching repositories: ${response?.statusText}`); + } + + let data = await response.json(); + data = data + .filter(repo => repo.name !== "tools") + .filter(repo => !repo.archived) + .filter(repo => repo.topics.includes("nf-core") && repo.topics.includes("workflow")); + if (data.length === 0) { + break; + } // No more repositories + + this.repositories = this.repositories.concat(data); + page++; + } + this.requestUpdate(); + } catch (error) { + console.error(error); + } + } + + render() { + if (this.isLoading) { + return html``; + } + + return html` +
    + + +
    + `; + } + + getDefaultConfig() { + return { + showTableHeader: false, + display: { + float: "left" + }, + search: { + fields: ["name", "topics", "description"], + ignoreCase: true + }, + sortBy: { + options: [ + { + id: "name", + name: "Name", + }, + { + id: "stargazers_count", + name: "Stars", + order: "desc" + }, + { + id: "updated_at", + name: "Recently updated", + order: "desc" + } + ] + }, + groupBy: { + options: [] + }, + table: { + classes: "table table-hover table-borderless", + theadClasses: "table-light", + buttonsClass: "light", + iconsPrefix: GridCommons.GRID_ICONS_PREFIX, + icons: GridCommons.GRID_ICONS, + pagination: false, + pageSize: 100, + pageList: [100], + detailView: false, + rowStyle: "" + }, + columns: [ + { + title: "Name", + field: "full_name", + rowspan: 1, + colspan: 1, + formatter: (value, row) => { + return ` +
    +
    ${value} + +
    +
    ${row.description}
    +
    + `; + }, + width: "50", + widthUnit: "%" + }, + // { + // title: "Topics", + // field: "topics", + // rowspan: 1, + // colspan: 1, + // }, + { + title: "Stars", + field: "stargazers_count", + rowspan: 1, + colspan: 1, + formatter: value => { + return ` +
    + + + ${value} + + +
    + `; + } + }, + { + title: "Default branch", + field: "default_branch", + rowspan: 1, + colspan: 1, + formatter: (value, row) => { + return ` +
    +
    + Branch: ${value} + +
    +
    Updated ${UtilsNew.dateFormatter(row.updated_at)}
    +
    + `; + } + }, + { + title: "Add", + field: "add", + rowspan: 1, + colspan: 1, + formatter: () => { + return ` + + `; + }, + events: { + "click button": (e, value, row) => this.onAdd(e, row) + } + }, + ], + grid: { + display: { + columns: 3, + rowClass: "g-2", + cellClass: "p-2" + }, + render: data => { + return html` +
    +
    +

    + ${data.id} +

    +
    +
    + ${data.description} +
    +
    + `; + } + } + }; + } + +} + +customElements.define("workflow-import", WorkflowImport); diff --git a/src/webcomponents/workflow/workflow-manager.js b/src/webcomponents/workflow/workflow-manager.js index 96ccd277a..239644f73 100644 --- a/src/webcomponents/workflow/workflow-manager.js +++ b/src/webcomponents/workflow/workflow-manager.js @@ -17,12 +17,15 @@ import {html, LitElement} from "lit"; import UtilsNew from "../../core/utils-new.js"; import LitUtils from "../commons/utils/lit-utils.js"; +import "../commons/data-list.js"; import "./workflow-summary.js"; import "./workflow-view.js"; import "./workflow-create.js"; import "./workflow-update.js"; +import "./workflow-import.js"; import "./analysis/workflow-analysis.js"; import ModalUtils from "../commons/modal/modal-utils"; +import GridCommons from "../commons/grid-commons"; export default class WorkflowManager extends LitElement { @@ -53,12 +56,19 @@ export default class WorkflowManager extends LitElement { #init() { // this._prefix = UtilsNew.randomString(8); - this.WORKFLOW_TYPES = [ - {id: "SECONDARY_ANALYSIS", name: "Secondary Analysis"}, - {id: "RESEARCH_ANALYSIS", name: "Research Analysis"}, - {id: "CLINICAL_INTERPRETATION_ANALYSIS", name: "Clinical Interpretation Analysis"}, - {id: "OTHER", name: "Other"}, - ]; + // this.WORKFLOW_TYPES = [ + // {id: "SECONDARY_ANALYSIS", name: "Secondary Analysis"}, + // {id: "RESEARCH_ANALYSIS", name: "Research Analysis"}, + // {id: "CLINICAL_INTERPRETATION_ANALYSIS", name: "Clinical Interpretation Analysis"}, + // {id: "OTHER", name: "Other"}, + // ]; + + this.WORKFLOW_TYPES_COLOR_MAP = { + SECONDARY_ANALYSIS: "blue", + RESEARCH_ANALYSIS: "orange", + CLINICAL_INTERPRETATION_ANALYSIS: "red", + OTHER: "black", + }; this._config = this.getDefaultConfig(); } @@ -66,7 +76,7 @@ export default class WorkflowManager extends LitElement { update(changedProperties) { // Set workflows from the active study if (changedProperties.has("opencgaSession")) { - this.workflows = this.opencgaSession?.study?.workflows; + this.opencgaSessionObserver(); } // Merge the default config with the received config @@ -80,126 +90,52 @@ export default class WorkflowManager extends LitElement { super.update(changedProperties); } - onWorkflowUpdate() { - LitUtils.dispatchCustomEvent(this, "workflowUpdate", null, { - workflow: this.clinicalAnalysis, - }); - } - - renderItemAction(workflow, action, icon, name, disabled = false) { - return html` -
  • - - ${name} - -
  • - `; - } - - renderWorkflows(workflows, type) { - const filteredWorkflows = workflows.filter(workflow => workflow.type === type); - if (filteredWorkflows.length === 0) { - return html` -
    - -
    - `; - } else { - return html` - ${filteredWorkflows.map(workflow => html` -
    -
    -
    -
    - ${workflow.id} - ${workflow.name} -
    -
    -
    -
    - - - - -
    -
    -
    - - - -
    `)} - `; + opencgaSessionObserver() { + this.workflows = this.opencgaSession?.study?.workflows; + if (this.opencgaSession) { + if (this.workflows?.length === 0) { + this.opencgaSession.opencgaClient.workflows() + .search( + { + study: this.opencgaSession.study.fqn, + limit: 100, + count: true + }) + .then(response => { + this.workflows = response.getResults(); + }) + .catch(error => { + console.error(error); + params.error(error); + }).finally(() => { + this.requestUpdate(); + }); + } } - - // this.requestUpdate(); } - onActionClick(e) { + onActionClick(e, value, workflow) { e.preventDefault(); - const {action, workflowId, workflow} = e.currentTarget.dataset; - const workflowCallback = () => { - this.onWorkflowUpdate(); - }; + const action = e.currentTarget.dataset.action; switch (action) { case "view": - this.workflowUpdateId = workflowId; + this.workflowUpdateId = workflow.id; this.requestUpdate(); // await this.updateComplete; ModalUtils.show(`${this._prefix}ViewModal`); break; - case "copy-json": - const a = JSON.parse(workflow); - UtilsNew.copyToClipboard(JSON.stringify(a, null, "\t")); + case "copy": + UtilsNew.copyToClipboard(JSON.stringify(workflow, null, "\t")); break; case "execute": - this.workflowUpdateId = workflowId; + this.workflowUpdateId = workflow.id; this.requestUpdate(); // await this.updateComplete; ModalUtils.show(`${this._prefix}ExecuteModal`); break; case "edit": - this.workflowUpdateId = workflowId; + this.workflowUpdateId = workflow.id; this.requestUpdate(); // await this.updateComplete; ModalUtils.show(`${this._prefix}UpdateModal`); @@ -210,6 +146,71 @@ export default class WorkflowManager extends LitElement { } } + onWorkflowCreate(e) { + ModalUtils.show(`${this._prefix}CreateModal`); + } + + renderCreateModal() { + return ModalUtils.create(this, `${this._prefix}CreateModal`, { + display: { + modalTitle: `Create a new Workflow`, + modalDraggable: true, + modalCyDataName: "modal-execute", + modalSize: "modal-xl" + }, + render: () => html` + + + `, + }); + } + + onWorkflowImport(e) { + ModalUtils.show(`${this._prefix}ImportModal`); + } + + onWorkflowImported(e) { + this.opencgaSession.opencgaClient.workflows() + .search( + { + study: this.opencgaSession.study.fqn, + limit: 100, + count: true + }) + .then(response => { + this.workflows = response.getResults(); + }) + .catch(error => { + console.error(error); + params.error(error); + }).finally(() => { + this.requestUpdate(); + }); + } + + renderImportModal() { + return ModalUtils.create(this, `${this._prefix}ImportModal`, { + display: { + modalTitle: `Import NextFlow Workflows`, + modalDraggable: true, + modalCyDataName: "modal-execute", + modalSize: "modal-xl" + }, + render: () => html` + + + `, + }); + } + renderViewModal() { return ModalUtils.create(this, `${this._prefix}ViewModal`, { display: { @@ -239,13 +240,19 @@ export default class WorkflowManager extends LitElement { render: () => html` `, }); } + onWorkflowUpdate() { + LitUtils.dispatchCustomEvent(this, "workflowUpdate", null, { + workflow: this.clinicalAnalysis, + }); + } + renderUpdateModal() { return ModalUtils.create(this, `${this._prefix}UpdateModal`, { display: { @@ -259,7 +266,8 @@ export default class WorkflowManager extends LitElement { .workflowId="${this.workflowUpdateId}" .active="${active}" .displayConfig="${{mode: "page", type: "tabs", buttonsLayout: "upper"}}" - .opencgaSession="${this.opencgaSession}"> + .opencgaSession="${this.opencgaSession}" + @workflowUpdate="${this.onWorkflowUpdate}"> `, }); @@ -277,37 +285,30 @@ export default class WorkflowManager extends LitElement { return html`
    -
    +

    Workflows

    -
    - - +
    + +
    - ${this.WORKFLOW_TYPES.map(type => html` -
    -

    ${type.name}

    - ${this.renderWorkflows(this.workflows, type.id)} -
    - `)} +
    + + +
    +
    + ${this.renderCreateModal()} + ${this.renderImportModal()} ${this.renderViewModal()} ${this.renderExecuteModal()} ${this.renderUpdateModal()} @@ -315,7 +316,207 @@ export default class WorkflowManager extends LitElement { } getDefaultConfig() { - return {}; + return { + showTableHeader: false, + display: { + float: "right" + }, + search: { + fields: ["id", "name", "description"], + ignoreCase: true + }, + sortBy: { + options: [ + { + id: "name", + name: "Name", + // order: "asc" + }, + { + id: "modificationDate", + name: "Recently updated", + order: "desc" + }, + { + id: "creationDate", + name: "Created", + order: "desc" + }, + ] + }, + groupBy: { + options: [ + { + id: "type", + name: "Workflow Type", + values: ["SECONDARY_ANALYSIS", "RESEARCH_ANALYSIS", "CLINICAL_INTERPRETATION_ANALYSIS", "OTHER"] + }, + { + id: "tags", + name: "Tags", + } + ] + }, + table: { + classes: "table table-hover table-borderless", + theadClasses: "table-light", + buttonsClass: "light", + iconsPrefix: GridCommons.GRID_ICONS_PREFIX, + icons: GridCommons.GRID_ICONS, + pagination: false, + pageSize: 100, + pageList: [100], + detailView: false, + rowStyle: "" + }, + columns: [ + { + title: "ID", + field: "id", + rowspan: 1, + colspan: 1, + formatter: (value, row) => { + return ` +
    + +
    Version ${row.version}
    +
    + `; + } + }, + { + title: "Name", + field: "name", + rowspan: 1, + colspan: 1, + formatter: (value, row) => { + return ` +
    + +
    ${row.description}
    +
    + `; + } + }, + { + title: "Scripts", + field: "scripts", + rowspan: 1, + colspan: 1, + formatter: (value, row) => { + if (value.length > 0) { + return ` +
    + ${value?.map(script => `${script.fileName}`).join("
    ")} +
    + `; + } else { + return ` +
    + +
    ${row.repository?.version || ""}
    +
    + `; + } + } + }, + { + title: "Tags", + field: "tags", + rowspan: 1, + colspan: 1, + formatter: value => { + return ` +
    + +
    + `; + } + }, + { + title: "Type", + field: "type", + rowspan: 1, + colspan: 1 + }, + { + title: "Modification Date", + field: "modificationDate", + rowspan: 1, + colspan: 1, + formatter: value => { + return ` +
    +
    Updated ${UtilsNew.dateFormatter(value)}
    +
    + `; + } + }, + { + title: "Actions", + field: "actions", + rowspan: 1, + colspan: 1, + formatter: () => { + return ` + + `; + }, + events: { + "click a": (e, value, row) => this.onActionClick(e, value, row) + }, + } + ], + grid: { + display: { + columns: 3, + rowClass: "g-2", + cellClass: "p-2" + }, + render: data => { + return html` +
    +
    +

    + ${data.id} +

    +
    +
    + ${data.description} +
    +
    + `; + } + } + }; } } diff --git a/src/webcomponents/workflow/workflow-update.js b/src/webcomponents/workflow/workflow-update.js index a78918a8d..a37cbf9dc 100644 --- a/src/webcomponents/workflow/workflow-update.js +++ b/src/webcomponents/workflow/workflow-update.js @@ -225,7 +225,7 @@ export default class WorkflowUpdate extends LitElement { title: "Type", field: "variables[].type", type: "select", - allowedValues: ["INT", "STRING", "BOOLEAN", "FLAG"], + allowedValues: ["FLAG", "BOOLEAN", "INTEGER", "DOUBLE", "STRING", "FILE"], display: { placeholder: "Add a content...", }, diff --git a/styles/css/global.css b/styles/css/global.css index 251d2d9a3..acea5897c 100644 --- a/styles/css/global.css +++ b/styles/css/global.css @@ -512,11 +512,11 @@ select.no-data + .select2-container--bootstrap-5.select2-container--open.select2 /* Terrible hack to make sure that the bootstrap table has a minimum height of 200px */ /* This is required to prevent the DNA loader exceed the table container */ -.bootstrap-table { - min-height: 200px; -} +/*.bootstrap-table {*/ +/* min-height: 200px;*/ +/*}*/ /* Sets a minimum height to the loading container, to make sure that the loading DNA fits on this space */ .bootstrap-table .fixed-table-loading { - min-height: 110px; + min-height: 100px; }