diff --git a/cromwell-api-rs/examples/boxplot.rs b/cromwell-api-rs/examples/boxplot.rs index 7a65ee7..4ff82d5 100644 --- a/cromwell-api-rs/examples/boxplot.rs +++ b/cromwell-api-rs/examples/boxplot.rs @@ -31,7 +31,7 @@ async fn main() -> Result<(), CromwellError> { let workflow_uuid = uuid::Uuid::new_v4().to_string(); let workflow_source = Some(Path::new("examples/boxplot/workflow.wdl")); - let workflow_inputs = Some(Path::new("examples/boxplot/inputs_example.json")); + let workflow_inputs = Some(Path::new("examples/boxplot/inputs.json")); let status = match client .submit_workflow( diff --git a/cromwell-api-rs/examples/boxplot/schema.json b/cromwell-api-rs/examples/boxplot/schema.json index 9d13448..6836c9e 100644 --- a/cromwell-api-rs/examples/boxplot/schema.json +++ b/cromwell-api-rs/examples/boxplot/schema.json @@ -2,6 +2,21 @@ "readme": "/workflows/boxplot-v0.1.0/README.md", "schema": { "fields": [ + { + "key": "dataset", + "dataIndex": "dataset", + "valueType": "dataset_searcher", + "title": "Dataset", + "tooltip": "Dataset to use for boxplot.", + "formItemProps": { + "rules": [ + { + "required": true, + "message": "Dataset is required." + } + ] + } + }, { "key": "exp_file", "dataIndex": "exp_file", @@ -14,7 +29,8 @@ "required": true, "message": "Expression matrix file is required." } - ] + ], + "hidden": true } }, { @@ -29,13 +45,14 @@ "required": true, "message": "Sample information file is required." } - ] + ], + "hidden": true } }, { "key": "which_gene_symbols", "dataIndex": "which_gene_symbols", - "valueType": "select", + "valueType": "gene_searcher", "fieldProps": { "mode": "multiple" }, @@ -48,11 +65,6 @@ "message": "Which gene symbols is required." } ] - }, - "valueEnum": { - "BDNF": "BDNF", - "TP53": "TP53", - "EGFR": "EGFR" } }, { @@ -71,10 +83,6 @@ }, "fieldProps": { "mode": "multiple" - }, - "valueEnum": { - "Female_MECFS": "Female_MECFS", - "Female_Control": "Female_Control" } }, { @@ -88,7 +96,10 @@ "kruskal.test": "kruskal.test" }, "title": "Method", - "tooltip": "Method to use for boxplot. Supported methods: `t.test`, `wilcox.test`, `anova`, `kruskal.test`. Default: `t.test`." + "tooltip": "Method to use for boxplot. Supported methods: `t.test`, `wilcox.test`, `anova`, `kruskal.test`. Default: `t.test`.", + "formItemProps": { + "initialValue": "t.test" + } }, { "key": "log_scale", @@ -99,7 +110,15 @@ "FALSE": "FALSE" }, "title": "Log Scale", - "tooltip": "Whether to use log scale for boxplot. TRUE or FALSE. Default: FALSE." + "tooltip": "Whether to use log scale for boxplot. TRUE or FALSE. Default: FALSE.", + "formItemProps": { + "rules": [ + { + "required": true, + "message": "Log scale is required." + } + ] + } }, { "key": "enable_label", @@ -110,7 +129,10 @@ "FALSE": "FALSE" }, "title": "Enable Label", - "tooltip": "Whether to enable label for boxplot. TRUE or FALSE. Default: FALSE." + "tooltip": "Whether to enable label for boxplot. TRUE or FALSE. Default: FALSE.", + "formItemProps": { + "initialValue": "FALSE" + } }, { "key": "enable_log2fc", @@ -121,7 +143,10 @@ "FALSE": "FALSE" }, "title": "Enable Log2FC", - "tooltip": "Whether to enable log2 fold change for boxplot. TRUE or FALSE. Default: FALSE." + "tooltip": "Whether to enable log2 fold change for boxplot. TRUE or FALSE. Default: FALSE.", + "formItemProps": { + "initialValue": "TRUE" + } } ], "examples": [ diff --git a/cromwell-api-rs/examples/boxplot/workflow.wdl b/cromwell-api-rs/examples/boxplot/workflow.wdl index b1a105c..b747c25 100644 --- a/cromwell-api-rs/examples/boxplot/workflow.wdl +++ b/cromwell-api-rs/examples/boxplot/workflow.wdl @@ -70,10 +70,15 @@ task boxplot_task { EOF Rscript ~{script_dir}/boxplot.R args.json + + cp ~{exp_file} ./ + cp ~{sample_info_file} ./ >>> output { File metadata = "metadata.json" File out_plot = "output.json" + File exp_file = "~{basename(exp_file)}" + File sample_info_file = "~{basename(sample_info_file)}" } } diff --git a/studio/src/StatEngine/components/ArgumentForm/index.tsx b/studio/src/StatEngine/components/ArgumentForm/index.tsx index 9ab3dc1..159b2d4 100644 --- a/studio/src/StatEngine/components/ArgumentForm/index.tsx +++ b/studio/src/StatEngine/components/ArgumentForm/index.tsx @@ -1,9 +1,9 @@ import { DownloadOutlined, EditOutlined, UploadOutlined } from '@ant-design/icons'; import type { ProFormColumnsType, ProFormLayoutType } from '@ant-design/pro-form'; import { BetaSchemaForm, ProProvider, ProFormSelect } from '@ant-design/pro-components'; -import { Button, Col, Empty, Row, Space, Tooltip, Form } from 'antd'; -// import GeneSearcher from '@/components/GeneSearcher'; -// import { GenesQueryParams, GeneDataResponse } from '@/components/GeneSearcher'; +import { Button, Col, Empty, Row, Space, Tooltip, Form, Select } from 'antd'; +import GeneSearcher from '../GeneSearcher'; +import { GenesQueryParams, GeneDataResponse } from '../GeneSearcher'; import FormItem from 'antd/lib/form/FormItem'; import React, { memo, useContext, useEffect, useState } from 'react'; import type { TaskHistory } from '../WorkflowList/data'; @@ -15,6 +15,19 @@ type DataItem = { state: string; }; +const datasets = [ + { + datasetName: "GSE251790_Female_CSF", + fieldValue: { + exp_file: "/data/biomedgps/cromwell/data/gene_expression.tsv", + sample_info_file: "/data/biomedgps/cromwell/data/sample_info.tsv", + }, + fieldValueEnum: { + which_groups: ["Female_MECFS", "Female_Control"] + } + } +] + export type ArgumentProps = { // queryGenes: (params: GenesQueryParams) => Promise; columns: ProFormColumnsType[]; @@ -27,7 +40,9 @@ export type ArgumentProps = { }; const ArgumentForm: React.FC = (props) => { - const { columns, height, labelSpan, onSubmit, fieldsValue } = props; + const { height, labelSpan, onSubmit, fieldsValue } = props; + + const [columns, setColumns] = useState[]>(props.columns); const activateBtn = ( = (props) => { const [layoutType, setLayoutType] = useState('QueryFilter'); const [form] = Form.useForm(); + // Ensure columns are initialized on first load useEffect(() => { - form.resetFields() - }, [columns]) + if (props.columns && props.columns.length > 0) { + setColumns(props.columns); + } + }, [props.columns]); useEffect(() => { if (fieldsValue) { @@ -64,23 +82,65 @@ const ArgumentForm: React.FC = (props) => { {text}, - // renderFormItem: (text: any, props: any) => { - // console.log("Gene Searcher Component: ", props, form.getFieldValue(props?.id)) - // const initialValue = form.getFieldValue(props?.id) - // return () - // }, - // } - // }, + valueTypeMap: { + gene_searcher: { + render: (text: any) => {text}, + renderFormItem: (text: any, props: any) => { + console.log("Gene Searcher Component: ", props, form.getFieldValue(props?.id)) + const initialValue = form.getFieldValue(props?.id) + return () + }, + }, + dataset_searcher: { + render: (text: any) => {text}, + renderFormItem: (text: any, props: any) => { + console.log("Dataset Searcher Component: ", props, text) + return + }, + } + } }} > @@ -135,7 +195,7 @@ const ArgumentForm: React.FC = (props) => { ) : ( - + ); }; diff --git a/studio/src/StatEngine/components/GeneSearcher/index.tsx b/studio/src/StatEngine/components/GeneSearcher/index.tsx new file mode 100644 index 0000000..7fed0b3 --- /dev/null +++ b/studio/src/StatEngine/components/GeneSearcher/index.tsx @@ -0,0 +1,217 @@ +import { Select, Empty, Tag, message } from 'antd'; +import { filter, orderBy } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { fetchEntities } from '@/services/swagger/KnowledgeGraph'; +import type { OptionType, Entity, ComposeQueryItem, QueryItem } from 'biominer-components/dist/typings'; + +const { Option } = Select; + +export type GeneDataResponse = { + total: number; + page: number; + page_size: number; + data: Entity[]; +}; + +export function makeQueryEntityStr(params: Partial, order?: string[]): string { + let query: ComposeQueryItem = {} as ComposeQueryItem; + + let label_query_item = {} as QueryItem; + + label_query_item = { + operator: '=', + field: 'label', + value: 'Gene', + }; + + let filteredKeys = filter(Object.keys(params), (key) => key !== 'label'); + if (filteredKeys.length > 1) { + query = { + operator: 'or', + items: [], + }; + + if (order) { + // Order and filter the keys. + filteredKeys = order.filter((key) => filteredKeys.includes(key)); + } + } else { + query = { + operator: 'and', + items: [], + }; + } + + query['items'] = filteredKeys.map((key) => { + return { + operator: 'ilike', + field: key, + value: `%${params[key as keyof Entity]}%`, + }; + }); + + if (label_query_item.field) { + if (query['operator'] === 'and') { + query['items'].push(label_query_item); + } else { + query = { + operator: 'and', + items: [query, label_query_item], + }; + } + } + + return JSON.stringify(query); +} + +export type GenesQueryParams = { + /** Query string with biomedgps specification. */ + query_str: string; + /** Page, From 1. */ + page?: number; + /** Num of items per page. */ + page_size?: number; +}; + +export type GeneSearcherProps = { + placeholder?: string; + initialValue?: any; + mode?: any; + // When multiple values was returned, the gene variable will be undefined. + onChange?: (value: string | string[], gene: Entity | undefined) => void; + style: React.CSSProperties; +}; + +const GeneSearcher: React.FC = props => { + const { initialValue } = props; + const [geneData, setGeneData] = useState([]); + const [data, setData] = useState([]); + const [value, setValue] = useState(); + + let timeout: ReturnType | null; + const fetchGenes = async ( + value: string, + callback: (options: OptionType[]) => void, + ) => { + // We might not get good results when the value is short than 3 characters. + if (value.length < 3) { + callback([]); + return; + } + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + + // TODO: Check if the value is a valid id. + + let queryMap = {}; + let order: string[] = []; + // If the value is a number, then maybe it is an id or xref but not for name or synonyms. + if (value && !isNaN(Number(value))) { + queryMap = { id: value, xrefs: value }; + order = ['id', 'xrefs', 'label']; + } else { + queryMap = { name: value, synonyms: value, xrefs: value, id: value }; + order = ['name', 'synonyms', 'xrefs', 'id', 'label']; + } + + const fetchData = () => { + fetchEntities({ + query_str: makeQueryEntityStr(queryMap, order), + page: 1, + page_size: 50, + // We only want to get all valid entities. + model_table_prefix: 'biomedgps', + }) + .then((response) => { + const { records } = response; + // @ts-ignore + const options: OptionType[] = records.map((item: Entity, index: number) => ({ + order: index, + value: `${item['name']}`, + label: {`${item['name']} | ${item['id']}`}, + description: item['description'], + metadata: item, + })); + console.log('getLabels results: ', options); + callback(orderBy(options, ['value'])); + setGeneData(records as Entity[]); + }) + .catch((error) => { + if (error.response.status === 401) { + message.warning("Please login to see the search results.") + } else { + message.warning("Cannot get search results for your query. Please try again later.") + } + console.log('requestNodes Error: ', error); + callback([]); + }); + }; + + timeout = setTimeout(fetchData, 300); + }; + + useEffect(() => { + // To avoid the loop updating. + if (initialValue && initialValue !== value) { + setValue(initialValue) + fetchGenes(initialValue, (options) => { + setData(options); + handleChange(initialValue, {}); + }) + } + }, [initialValue]) + + const handleSearch = (newValue: string) => { + if (newValue) { + fetchGenes(newValue, setData); + } else { + setData([]); + } + }; + + const handleChange = (newValue: string, option: any) => { + setValue(newValue); + console.log("GeneSearcher handleChange: ", newValue); + if (newValue && typeof newValue == 'string') { + const gene = filter(geneData, (item) => { + if (newValue.match(/[a-zA-Z][a-zA-Z0-9]+/i)) { + return item.name == newValue + } else if (newValue.match(/[0-9]+/i)) { + return item.id.toString() == newValue + } else { + return false + } + }) + + console.log("handleChange(GeneSearcher): ", gene, geneData); + props.onChange?.(newValue, gene[0]); + } else { + props.onChange?.(newValue, undefined); + } + }; + + const options = data.map(d => ); + + return ( + + ); +}; + +export default GeneSearcher; \ No newline at end of file diff --git a/studio/src/StatEngine/components/ResultPanel/index.less b/studio/src/StatEngine/components/ResultPanel/index.less index 1c2a318..4fc11a7 100644 --- a/studio/src/StatEngine/components/ResultPanel/index.less +++ b/studio/src/StatEngine/components/ResultPanel/index.less @@ -2,6 +2,11 @@ width: 100%; height: 100%; + .ant-spin-nested-loading, .ant-spin-container { + height: 100%; + width: 100%; + } + .ant-tabs-content { position: unset; } diff --git a/studio/src/StatEngine/components/ResultPanel/index.tsx b/studio/src/StatEngine/components/ResultPanel/index.tsx index 8522488..eedb98b 100644 --- a/studio/src/StatEngine/components/ResultPanel/index.tsx +++ b/studio/src/StatEngine/components/ResultPanel/index.tsx @@ -7,7 +7,7 @@ import { // SnippetsOutlined, DatabaseOutlined } from '@ant-design/icons'; -import { Button, Col, Drawer, Empty, Row, Space, Tabs, Tooltip, message, Badge } from 'antd'; +import { Button, Col, Drawer, Empty, Row, Space, Tabs, Tooltip, message, Badge, Spin } from 'antd'; import React, { memo, useEffect, useState, useRef } from 'react'; import WorkflowList from '../WorkflowList'; @@ -67,29 +67,43 @@ const ResultPanel: React.FC = (props) => { const [chartsVisible, setChartsVisible] = useState(false); const [editBtnActive, setEditBtnActive] = useState(false); const [historyVisible, setHistoryVisible] = useState(false); - const [activeKey, setActiveKey] = useState("chart"); + const [activeKey, setActiveKey] = useState("chart1"); - const [plotData, setPlotData] = useState(null); - const [columnDefs, setColumnDefs] = useState(null); - const [plotlyData, setPlotlyData] = useState(null); + const [loading, setLoading] = useState(false); + const [plotData, setPlotData] = useState(null); + const [columnDefs, setColumnDefs] = useState(null); + const [plotlyData, setPlotlyData] = useState(null); const [taskDuration, setTaskDuration] = useState('0s'); const intervalId = useRef(null); useEffect(() => { if (charts.length > 0 && task) { + setLoading(true); console.log('Chart Task: ', task.task_id); - fetchFileByFileName({ - task_id: task.task_id, - file_name: charts[0].filename - }).then((response: any) => { - setPlotlyData({ - data: response.data, - layout: response.layout, - frames: response.frames || undefined + const tempPlotlyData: PlotlyChart[] = []; + const promises: Promise[] = []; + charts.forEach((chart) => { + promises.push(fetchFileByFileName({ + task_id: task.task_id, + file_name: chart.filename + })); + }); + + Promise.all(promises).then((responses) => { + responses.forEach((response) => { + tempPlotlyData.push({ + data: response.data, + layout: response.layout, + frames: response.frames || undefined + }); }); + + setPlotlyData(tempPlotlyData); + setLoading(false); }).catch(error => { message.warning("Cannot fetch the result, please retry later.") + setLoading(false); }); } }, [charts]); @@ -97,50 +111,65 @@ const ResultPanel: React.FC = (props) => { useEffect(() => { if (files.length > 0 && task) { console.log('Data: ', task.task_id); - const filetype = files[0].filename && files[0].filename.split('.')[1]; - - fetchFileByFileName({ - task_id: task.task_id, - file_name: files[0].filename - }).then((response: any) => { - console.log('File Data: ', response, response.length); - let trimmedResponse = response.trim(); - if (!trimmedResponse || trimmedResponse.length === 0) { - setPlotData(null); - return; - } else { - Papa.parse(trimmedResponse, { - header: true, - delimiter: filetype === 'tsv' ? '\t' : ',', - skipEmptyLines: true, - dynamicTyping: true, - complete: function (results: any) { - const parsedData = results.data; - setPlotData(parsedData) - - if (parsedData.length > 0) { - const firstRow = parsedData[0]; - const columns = Object.keys(firstRow).map((key) => { - return { - headerName: key, - field: key, - sortable: true, - filter: true - } - }); + setLoading(true); + const promises: Promise[] = []; + files.forEach((file) => { + promises.push(fetchFileByFileName({ + task_id: task.task_id, + file_name: file.filename + })); + }); - setColumnDefs(columns); + let tempPlotData: any[] = []; + let tempColumnDefs: any[] = []; + Promise.all(promises).then((responses) => { + responses.forEach((response: any, index: number) => { + console.log('File Data: ', response, response.length); + let trimmedResponse = response.trim(); + if (!trimmedResponse || trimmedResponse.length === 0) { + tempPlotData[index] = null; + return; + } else { + Papa.parse(trimmedResponse, { + header: true, + delimiter: files[index].filename && files[index].filename.split('.')[1] === 'tsv' ? '\t' : ',', + skipEmptyLines: true, + dynamicTyping: true, + complete: function (results: any) { + const parsedData = results.data; + + tempPlotData[index] = parsedData; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + const columns = Object.keys(firstRow).map((key) => { + return { + headerName: key, + field: key, + sortable: true, + filter: true + } + }); + + tempColumnDefs[index] = columns; + } + }, + error: function (error: any) { + message.warning("Cannot parse the result, the data may not be a valid CSV/TSV file.") + tempPlotData[index] = null; + tempColumnDefs[index] = null; } - }, - error: function (error: any) { - message.warning("Cannot parse the result, the data may not be a valid CSV/TSV file.") - setPlotData(null) - } - }); - } + }); + } + }); + + setPlotData(tempPlotData); + setColumnDefs(tempColumnDefs); + setLoading(false); }).catch(error => { message.warning("Cannot fetch the result, please retry later.") - setPlotData(null) + setPlotData(null); + setColumnDefs(null); + setLoading(false); }); } }, [files]) @@ -285,167 +314,211 @@ const ResultPanel: React.FC = (props) => { return ( - { setActiveKey(activeKey) }} - activeKey={activeKey} - className="tabs-result" - tabBarExtraContent={resultOperations}> - - - Figure - - } - key="chart" - > - - { - plotlyEditorMode === 'PlotlyEditor' ? ( - - ) : null - } - 0 ? charts[0].filename : 'random-string'} - mode={plotlyEditorMode} - /> - - - - - Log - - } - key="log" - > - - - { + + { setActiveKey(activeKey) }} + activeKey={activeKey} + className="tabs-result" + tabBarExtraContent={resultOperations}> - - Data + + Figure 1 } - key="data" + key="chart1" > - { - plotData ? -
- { }} - autoSizeStrategy={{ - type: 'fitCellContents' - }} - // pagination={true} - // paginationPageSize={30} - getContextMenuItems={(params: any) => { - var result = [ - 'copy', - 'copyWithHeaders', - 'copyWithGroupHeaders', - 'separator', - 'autoSizeAll', - 'resetColumns', - 'expandAll', - 'contractAll', - 'separator', - 'export', - ]; - return result; + + { + plotlyEditorMode === 'PlotlyEditor' ? ( + + ) : null + } + 0 ? charts[0].filename : 'random-string'} + mode={plotlyEditorMode} + /> + + + { + charts.length > 1 ? charts.slice(1).map((chart: FileMeta, index: number) => ( + + + Figure {index + 2} + + } + key={`chart${index + 2}`} + > + + { + plotlyEditorMode === 'PlotlyEditor' ? ( + + ) : null + } + -
: - + +
+ )) : + null + } + + + Log + } + key="log" + > + - } - { - chartTask ? - - - Metadata - - } - key="metadata" - > - { downloadAsJSON(chartTask, "download-anchor") }}> - Download Metadata - - - - : - null - } -
- { - setChartsVisible(false); - }} - open={chartsVisible} - > - ) => { - onClickItem(workflow.short_name, undefined, fieldsValue); + { + plotData ? + plotData.map((data: any[], index: number) => ( + + + Data [{files[index].filename}] + + } + key={`data${index}`} + > + { + data ? +
+ { }} + autoSizeStrategy={{ + type: 'fitCellContents' + }} + // pagination={true} + // paginationPageSize={30} + getContextMenuItems={(params: any) => { + var result = [ + 'copy', + 'copyWithHeaders', + 'copyWithGroupHeaders', + 'separator', + 'autoSizeAll', + 'resetColumns', + 'expandAll', + 'contractAll', + 'separator', + 'export', + ]; + return result; + }} + domLayout="autoHeight" + /> +
: + + } +
+ )) : + null + } + { + chartTask ? + + + Metadata + + } + key="metadata" + > + { downloadAsJSON(chartTask, "download-anchor") }}> + Download Metadata + + + + : + null + } +
+ { setChartsVisible(false); }} - /> - + open={chartsVisible} + > + ) => { + onClickItem(workflow.short_name, undefined, fieldsValue); + setChartsVisible(false); + }} + /> + - {/* = (props) => { }} > */} +
); }; diff --git a/studio/src/pages/OmicsData/TaskHistory.tsx b/studio/src/pages/OmicsData/TaskHistory.tsx index aeb61d9..0eb5778 100644 --- a/studio/src/pages/OmicsData/TaskHistory.tsx +++ b/studio/src/pages/OmicsData/TaskHistory.tsx @@ -39,8 +39,9 @@ const TaskHistoryTable: React.FC = forwardRef((props, ref }, { title: 'Description', - dataIndex: 'key_sentence', + dataIndex: 'description', fixed: 'left', + align: 'center', ellipsis: true, width: 'auto', }, @@ -94,6 +95,9 @@ const TaskHistoryTable: React.FC = forwardRef((props, ref dataIndex: 'status', align: 'center', width: 120, + render: (text) => { + return {text}; + }, }, { title: 'Actions', @@ -108,26 +112,21 @@ const TaskHistoryTable: React.FC = forwardRef((props, ref

Are you sure you want to delete this task?

- - - - + }); + }}>Delete - } trigger="click" open={popupVisible}> + } trigger="click">