diff --git a/src/content/app/tools/blast/blast-download/submissionDownload.ts b/src/content/app/tools/blast/blast-download/submissionDownload.ts index 5fa82af513..5397ea0122 100644 --- a/src/content/app/tools/blast/blast-download/submissionDownload.ts +++ b/src/content/app/tools/blast/blast-download/submissionDownload.ts @@ -100,7 +100,7 @@ const downloadBlastSubmission = async ( csv = createCSVForGenomicBlast(job.data); } else if (blastedAgainst === 'cdna') { csv = createCSVForTranscriptBlast(job.data); - } else if (blastedAgainst === 'protein') { + } else if (blastedAgainst === 'pep') { csv = createCSVForProteinBlast(job.data); } diff --git a/src/content/app/tools/blast/views/blast-submission-results/BlastSubmissionResults.tsx b/src/content/app/tools/blast/views/blast-submission-results/BlastSubmissionResults.tsx index f353548045..f1be2f0f2e 100644 --- a/src/content/app/tools/blast/views/blast-submission-results/BlastSubmissionResults.tsx +++ b/src/content/app/tools/blast/views/blast-submission-results/BlastSubmissionResults.tsx @@ -124,9 +124,8 @@ const Main = () => { key={data.sequence.id} species={data.species} sequence={data.sequence} - preset={blastSubmission.submittedData.preset} + submission={blastSubmission} blastResults={data.blastResults} - parameters={blastSubmission.submittedData.parameters} /> ) ); diff --git a/src/content/app/tools/blast/views/blast-submission-results/components/blast-results-per-sequence/BlastResultsPerSequence.tsx b/src/content/app/tools/blast/views/blast-submission-results/components/blast-results-per-sequence/BlastResultsPerSequence.tsx index c22441cdce..815cb33865 100644 --- a/src/content/app/tools/blast/views/blast-submission-results/components/blast-results-per-sequence/BlastResultsPerSequence.tsx +++ b/src/content/app/tools/blast/views/blast-submission-results/components/blast-results-per-sequence/BlastResultsPerSequence.tsx @@ -26,11 +26,10 @@ import SingleBlastJobResult from '../single-blast-job-result/SingleBlastJobResul import { parseBlastInput } from 'src/content/app/tools/blast/utils/blastInputParser'; import type { - BlastSubmissionParameters, - BlastJobWithResults + BlastJobWithResults, + BlastSubmission } from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice'; import type { Species } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice'; -import type { DatabaseType } from 'src/content/app/tools/blast/types/blastSettings'; import styles from './BlastResultsPerSequence.scss'; @@ -40,13 +39,19 @@ type BlastResultsPerSequenceProps = { value: string; }; species: Species[]; - preset: string; blastResults: BlastJobWithResults[]; - parameters: BlastSubmissionParameters; + submission: BlastSubmission; }; const BlastResultsPerSequence = (props: BlastResultsPerSequenceProps) => { - const { sequence, species, blastResults, parameters } = props; + const { + sequence, + species, + blastResults, + submission: { + submittedData: { parameters, preset } + } + } = props; const parsedBlastSequence = parseBlastInput(sequence.value)[0]; const { header: sequenceHeader, value: sequenceValue } = parsedBlastSequence; const sequenceHeaderLabel = @@ -73,7 +78,7 @@ const BlastResultsPerSequence = (props: BlastResultsPerSequenceProps) => { )} @@ -103,7 +108,7 @@ const BlastResultsPerSequence = (props: BlastResultsPerSequenceProps) => { species={speciesInfo} jobResult={result} diagramWidth={plotwidth} - blastDatabase={parameters.database as DatabaseType} + submission={props.submission} /> ); })} diff --git a/src/content/app/tools/blast/views/blast-submission-results/components/single-blast-job-result/SingleBlastJobResult.tsx b/src/content/app/tools/blast/views/blast-submission-results/components/single-blast-job-result/SingleBlastJobResult.tsx index 4efae466d4..2cb0c7aefe 100644 --- a/src/content/app/tools/blast/views/blast-submission-results/components/single-blast-job-result/SingleBlastJobResult.tsx +++ b/src/content/app/tools/blast/views/blast-submission-results/components/single-blast-job-result/SingleBlastJobResult.tsx @@ -24,12 +24,22 @@ import ShowHide from 'src/shared/components/show-hide/ShowHide'; import BlastHitsDiagram from 'src/content/app/tools/blast/components/blast-hits-diagram/BlastHitsDiagram'; import BlastSequenceAlignment from 'src/content/app/tools/blast/components/blast-sequence-alignment/BlastSequenceAlignment'; +import { + createCSVForGenomicBlast, + createCSVForProteinBlast, + createCSVForTranscriptBlast +} from 'src/content/app/tools/blast/blast-download/createBlastCSVTable'; +import { downloadTextAsFile } from 'src/shared/helpers/downloadAsFile'; + import type { BlastHit, BlastJobResult, HSP } from 'src/content/app/tools/blast/types/blastJob'; -import type { BlastJobWithResults } from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice'; +import type { + BlastJobWithResults, + BlastSubmission +} from 'src/content/app/tools/blast/state/blast-results/blastResultsSlice'; import type { Species } from 'src/content/app/tools/blast/state/blast-form/blastFormSlice'; import type { BlastSequenceAlignmentInput } from 'src/content/app/tools/blast/components/blast-sequence-alignment/blastSequenceAlignmentTypes'; import type { DatabaseType } from 'src/content/app/tools/blast/types/blastSettings'; @@ -48,7 +58,7 @@ type SingleBlastJobResultProps = { jobResult: BlastJobWithResults; species: Species; diagramWidth: number; - blastDatabase: DatabaseType; + submission: BlastSubmission; }; const hitsTableColumns: DataTableColumns = [ @@ -70,7 +80,12 @@ const hitsTableColumns: DataTableColumns = [ title: 'Length', isSortable: true }, - { width: '200px', columnId: 'view_alignment', isHideable: false }, + { + width: '200px', + columnId: 'view_alignment', + isHideable: false, + isExportable: false + }, { width: '100px', columnId: 'percentage_id', @@ -153,12 +168,7 @@ const hitsTableColumns: DataTableColumns = [ ]; const SingleBlastJobResult = (props: SingleBlastJobResultProps) => { - const { - species: speciesInfo, - jobResult, - diagramWidth, - blastDatabase - } = props; + const { species: speciesInfo, jobResult, diagramWidth, submission } = props; const [isExpanded, setExpanded] = useState(false); const alignmentsCount = countAlignments(jobResult.data); @@ -186,7 +196,7 @@ const SingleBlastJobResult = (props: SingleBlastJobResultProps) => { {isExpanded && ( - + )} ); @@ -194,10 +204,13 @@ const SingleBlastJobResult = (props: SingleBlastJobResultProps) => { type HitsTableProps = { jobResult: SingleBlastJobResultProps['jobResult']; - blastDatabase: DatabaseType; + submission: BlastSubmission; }; const HitsTable = (props: HitsTableProps) => { - const { jobResult, blastDatabase } = props; + const { jobResult, submission } = props; + + const blastDatabase = submission.submittedData.parameters + .database as DatabaseType; const [tableState, setTableState] = useState>({ rowsPerPage: 100, @@ -228,13 +241,12 @@ const HitsTable = (props: HitsTableProps) => { hitHsp.hsp_align_len, '', // view_alignment hitHsp.hsp_identity, - hitHsp.hsp_score, - , + hitHsp.hsp_bit_score, + getDynamicColumnContent({ + hit, + blastDatabase, + hitHsp + }), {hitHsp.hsp_hit_frame === '1' ? 'Forward' : 'Reverse'} , @@ -330,6 +342,19 @@ const HitsTable = (props: HitsTableProps) => { ); }; + const downloadHandler = async () => { + let csv = ''; + if (blastDatabase === 'dna') { + csv = createCSVForGenomicBlast(jobResult.data); + } else if (blastDatabase === 'cdna') { + csv = createCSVForTranscriptBlast(jobResult.data); + } else if (blastDatabase === 'pep') { + csv = createCSVForProteinBlast(jobResult.data); + } + + await downloadTextAsFile(csv, 'table.csv'); + }; + return (
{ expandedContent={expandedContent} disabledActions={[ TableAction.FILTERS, - TableAction.DOWNLOAD_ALL_DATA, + TableAction.FIND_IN_TABLE, TableAction.DOWNLOAD_SHOWN_DATA ]} + downloadHandler={downloadHandler} />
); @@ -373,7 +399,7 @@ type DynamicColumnContentProps = { blastDatabase: DatabaseType; }; -const DynamicColumnContent = (props: DynamicColumnContentProps) => { +const getDynamicColumnContent = (props: DynamicColumnContentProps) => { const { hit, blastDatabase, hitHsp } = props; if (blastDatabase !== 'dna') { diff --git a/src/shared/components/data-table/DataTable.tsx b/src/shared/components/data-table/DataTable.tsx index 2a92413036..3ea7572dd2 100644 --- a/src/shared/components/data-table/DataTable.tsx +++ b/src/shared/components/data-table/DataTable.tsx @@ -40,6 +40,8 @@ type TableContextType = DataTableState & { selectableColumnIndex: number; expandedContent: { [rowId: string]: ReactNode }; disabledActions?: TableAction[]; + downloadFileName?: string; + downloadHandler?: () => Promise; rows: TableRows; }; @@ -48,14 +50,16 @@ export const TableContext = React.createContext( ); export type TableProps = { - onStateChange?: (newState: DataTableState) => void; + state: Partial; columns: DataTableColumns; - state?: Partial; theme: TableTheme; selectableColumnIndex: number; - className?: string; expandedContent: { [rowId: string]: ReactNode }; disabledActions?: TableAction[]; + className?: string; + downloadFileName?: string; + downloadHandler?: () => Promise; + onStateChange?: (newState: DataTableState) => void; }; const DataTable = (props: TableProps) => { const initialDataTableState = { @@ -96,7 +100,9 @@ const DataTable = (props: TableProps) => { theme: props.theme, selectableColumnIndex: props.selectableColumnIndex, expandedContent: props.expandedContent, - disabledActions: props.disabledActions + disabledActions: props.disabledActions, + downloadFileName: props.downloadFileName, + downloadHandler: props.downloadHandler }} >
diff --git a/src/shared/components/data-table/components/table-controls/components/table-actions/TableActions.tsx b/src/shared/components/data-table/components/table-controls/components/table-actions/TableActions.tsx index edb2c46761..bb112a7327 100644 --- a/src/shared/components/data-table/components/table-controls/components/table-actions/TableActions.tsx +++ b/src/shared/components/data-table/components/table-controls/components/table-actions/TableActions.tsx @@ -21,6 +21,7 @@ import { TableContext } from 'src/shared/components/data-table/DataTable'; import RowVisibilityController from 'src/shared/components/data-table/components/main/components/table-row/components/row-visibility-controller/RowVisibilityController'; import FindInTable from './components/find-in-table/FindInTable'; import ShowHideColumns from './components/show-hide-columns/ShowHideColumns'; +import DownloadData from './components/download-data/DownloadData'; import { type DataTableState, @@ -54,7 +55,7 @@ const actionOptions = [ }, { value: TableAction.DOWNLOAD_ALL_DATA, - label: 'Download all data' + label: 'Download this table' }, { value: TableAction.RESTORE_DEFAULTS, @@ -144,6 +145,10 @@ const getActionComponent = (selectedAction: TableAction) => { return ; case TableAction.SHOW_HIDE_ROWS: return ; + case TableAction.DOWNLOAD_ALL_DATA: + return ; + case TableAction.DOWNLOAD_SHOWN_DATA: + return ; default: return null; } diff --git a/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.scss b/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.scss new file mode 100644 index 0000000000..27e4fffcda --- /dev/null +++ b/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.scss @@ -0,0 +1,14 @@ +@import 'src/styles/common'; + +.downloadData { + display: flex; + column-gap: 10px; + margin-left: 10px; + align-items: center; +} + +.cancel { + color: $blue; + cursor: pointer; + margin-left: 10px; +} diff --git a/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.tsx b/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.tsx new file mode 100644 index 0000000000..3736d34952 --- /dev/null +++ b/src/shared/components/data-table/components/table-controls/components/table-actions/components/download-data/DownloadData.tsx @@ -0,0 +1,158 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * 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 React, { ReactNode, useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom/client'; +import memoize from 'lodash/memoize'; + +import useDataTable from 'src/shared/components/data-table/hooks/useDataTable'; +import { downloadTextAsFile } from 'src/shared/helpers/downloadAsFile'; + +import { ControlledLoadingButton } from 'src/shared/components/loading-button'; + +import { TableAction } from 'src/shared/components/data-table/dataTableTypes'; +import { LoadingState } from 'src/shared/types/loading-state'; + +import styles from './DownloadData.scss'; + +const getReactRenderer = memoize(() => { + const element = document.createElement('div'); + const root = ReactDOM.createRoot(element); + + return { + element, + renderer: root + }; +}); + +const getReactNodeText = (node: ReactNode): string => { + const { element, renderer } = getReactRenderer(); + renderer.render(node); + return element.innerText; +}; + +const DownloadData = () => { + const { + dispatch, + rows, + downloadFileName, + columns, + selectedAction, + hiddenRowIds, + downloadHandler + } = useDataTable(); + + const [downloadState, setDownloadState] = useState( + LoadingState.NOT_REQUESTED + ); + const allowComponentResetRef = useRef(true); + + useEffect(() => { + allowComponentResetRef.current = true; + return () => { + allowComponentResetRef.current = false; + }; + }, []); + + const restoreDefaults = () => { + if (allowComponentResetRef.current) { + dispatch({ + type: 'set_selected_action', + payload: TableAction.DEFAULT + }); + } + }; + + const handleDownload = async () => { + setDownloadState(LoadingState.LOADING); + if (downloadHandler) { + try { + await downloadHandler(); + setDownloadState(LoadingState.SUCCESS); + setTimeout(restoreDefaults, 1000); + } catch { + setDownloadState(LoadingState.ERROR); + setTimeout(() => { + if (allowComponentResetRef.current) { + setDownloadState(LoadingState.NOT_REQUESTED); + } + }, 2000); + } + + return; + } + + const dataForExport: string[][] = []; + dataForExport[0] = [ + ...columns + .filter((column) => column.isExportable !== false) + .map((column) => column.title ?? '') + ]; + + const rowsToDownload = + selectedAction === TableAction.DOWNLOAD_ALL_DATA + ? rows + : rows.filter((row) => !hiddenRowIds[row.rowId]); + + rowsToDownload.forEach((row, rowIndex) => { + dataForExport[rowIndex + 1] = []; + row.cells.forEach((cell, cellIndex) => { + const { renderer, isExportable } = columns[cellIndex]; + + if (isExportable !== false) { + const cellExportData = renderer + ? renderer({ + rowData: row.cells, + rowId: String(row.rowId), + cellData: cell + }) + : cell; + + if (typeof cellExportData === 'string') { + dataForExport[rowIndex + 1].push(cellExportData); + } else if (typeof cellExportData === 'number') { + dataForExport[rowIndex + 1].push(String(cellExportData)); + } else { + dataForExport[rowIndex + 1].push(getReactNodeText(cellExportData)); + } + } + }); + }); + + const csv = formatCSV(dataForExport); + + downloadTextAsFile(csv, downloadFileName ?? 'Table export.csv'); + setDownloadState(LoadingState.SUCCESS); + setTimeout(restoreDefaults, 1000); + }; + + return ( +
+ {downloadFileName ?? 'table.csv'} + + Download + + + cancel + +
+ ); +}; + +const formatCSV = (table: (string | number)[][]) => { + return table.map((row) => row.join(',')).join('\n'); +}; + +export default DownloadData; diff --git a/src/shared/components/data-table/dataTableTypes.ts b/src/shared/components/data-table/dataTableTypes.ts index 51736ee41c..b4ae06b264 100644 --- a/src/shared/components/data-table/dataTableTypes.ts +++ b/src/shared/components/data-table/dataTableTypes.ts @@ -62,6 +62,7 @@ export type IndividualColumn = { isSearchable?: boolean; isFilterable?: boolean; isHideable?: boolean; + isExportable?: boolean; headerCellClassName?: string; bodyCellClassName?: string; helpText?: ReactNode;