From cb680ec2b23a7280868e6c031ae9b861be8f12b8 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Thu, 8 Jan 2026 23:09:09 -0300 Subject: [PATCH 01/16] Advances with Implement Modal to create a Biomarker from a result of a correlation experiment --- .../BiomarkerFromCorrelationModal.tsx | 1561 +++++++++++++++++ .../CreateBiomarkerButton.tsx | 90 + .../ResultTableControlForm.tsx | 287 +-- src/frontend/static/frontend/src/css/gem.css | 5 +- 4 files changed, 1805 insertions(+), 138 deletions(-) create mode 100644 src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx create mode 100644 src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx new file mode 100644 index 00000000..5fd6b445 --- /dev/null +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -0,0 +1,1561 @@ +import React from 'react' +// Update the import path to the correct location of Base component +import { Modal, DropdownItemProps, Icon, Form, Button } from 'semantic-ui-react' +import { DjangoCGDSStudy, DjangoMRNAxGEMResultRow, DjangoSurvivalColumnsTupleSimple, DjangoTag, DjangoUserFile } from '../../../utils/django_interfaces' +import ky, { Options } from 'ky' +import { getDjangoHeader, cleanRef, getFilenameFromSource, getDefaultSource } from '../../../utils/util_functions' +import { NameOfCGDSDataset, Nullable, CustomAlert, CustomAlertTypes, SourceType, ConfirmModal, ExperimentInfo, ExperimentResultTableControl } from '../../../utils/interfaces' +import { Biomarker, BiomarkerType, BiomarkerOrigin, FormBiomarkerData, MoleculesSectionData, MoleculesTypeOfSelection, SaveBiomarkerStructure, SaveMoleculeStructure, FeatureSelectionPanelData, SourceStateBiomarker, FeatureSelectionAlgorithm, FitnessFunction, FitnessFunctionParameters, BiomarkerState, AdvancedAlgorithm as AdvancedAlgorithmParameters, BBHAVersion, BiomarkerSimple, CrossValidationParameters } from '../../biomarkers/types' +import { ManualForm } from '../../biomarkers/modalContentBiomarker/manualForm/ManualForm' +import { PaginationCustomFilter } from '../../common/PaginatedTable' +import { isEqual } from 'lodash' +import { getDefaultClusteringParameters, getDefaultRFParameters, getDefaultSvmParameters } from '../../biomarkers/utils' + +// URLs defined in biomarkers.html +declare const urlBiomarkersCRUD: string +declare const urlBiomarkersSimpleUpdate: string +declare const urlBiomarkersCreate: string +declare const urlMiRNACodes: string +declare const urlGeneSymbols: string +declare const urlMethylationSites: string +declare const urlMiRNACodesFinder: string +declare const urlMethylationSitesFinder: string +declare const urlGeneSymbolsFinder: string + +const REQUEST_TIMEOUT = 120000 // 2 minutes in milliseconds +type SelectedOption = 'selectAll' | 'selectWithFilters' + +/** A matched molecule with the search query and the validated alias. */ +type MoleculeFinderResult = { molecule: string, standard: string } + +/** Extremely simple struct of a Biomarker (useful for simple updates). */ +type BiomarkerNameAndDesc = { + name: string, + description: string +} + +/** Some flags to validate the Biomarkers form. */ +type ValidationForm = { + haveAmbiguous: boolean, + haveInvalid: boolean +} + +interface BiomarkerFromCorrelationModalProps { + experimentInfo: ExperimentInfo; + tableControl: ExperimentResultTableControl; + onCreateBiomarker: (options: { + experimentInfo: ExperimentInfo; + selectAll: boolean; + tableControl?: ExperimentResultTableControl; + }) => void; +} + +/** BiomarkersPanel's state */ +interface BiomarkerFromCorrelationModalState { + biomarkers: BiomarkerSimple[], + newBiomarker: Biomarker, + /** PK of the Biomarker that's being loaded. */ + loadingFullBiomarkerId: Nullable, + selectedBiomarkerToDeleteOrSync: Nullable, + checkedIgnoreProposedAlias: boolean, + showDeleteBiomarkerModal: boolean, + /** Indicates if there's a Biomarker being deleted. */ + deletingBiomarker: boolean, + /** Indicates if there's a Biomarker being stopped. */ + stoppingExperiment: boolean, + /** Biomarker to stop. */ + biomarkerToStop: Nullable, + addingOrEditingBiomarker: boolean, + biomarkerTypeSelected: BiomarkerOrigin, + formBiomarker: FormBiomarkerData, + confirmModal: ConfirmModal + tags: DjangoTag[], + /** Indicates if the modal to create or edit a Biomarker is open. */ + openCreateEditBiomarkerModal: boolean, + /** Indicates if the modal to clone a Biomarker is open. Contains the pk of the Biomarker to clone. */ + biomarkerToClone: Nullable, + /** Indicates if there's a Biomarker being cloned. */ + cloningBiomarker: boolean, + /** Indicates if the modal to get the details of a Biomarker is open. */ + openDetailsModal: boolean, + /** Selected Biomarker instance to show its details. */ + selectedBiomarker: Nullable, + alert: CustomAlert, + featureSelection: FeatureSelectionPanelData, + submittingFSExperiment: boolean, + openDetailsModal2: boolean, + selectedOption: SelectedOption, + openSelectOptionModal: boolean +} + +/** + * Renders a CRUD panel for a Biomarker. + */ +export class BiomarkerFromCorrelationModal extends React.Component { + abortController = new AbortController() + constructor (props) { + super(props) + this.state = { + biomarkers: [], + newBiomarker: this.getDefaultNewBiomarker(), + loadingFullBiomarkerId: null, + biomarkerTypeSelected: BiomarkerOrigin.BASE, + checkedIgnoreProposedAlias: false, + showDeleteBiomarkerModal: false, + stoppingExperiment: false, + biomarkerToStop: null, + selectedBiomarkerToDeleteOrSync: null, + deletingBiomarker: false, + addingOrEditingBiomarker: false, + formBiomarker: this.getDefaultFormBiomarker(), + confirmModal: this.getDefaultConfirmModal(), + tags: [], + openCreateEditBiomarkerModal: false, + cloningBiomarker: false, + biomarkerToClone: null, + openDetailsModal: false, + selectedBiomarker: null, + alert: this.getDefaultAlertProps(), + featureSelection: this.getDefaultFeatureSelectionProps(), + submittingFSExperiment: false, + openDetailsModal2: false, + selectedOption: 'selectAll', + openSelectOptionModal: false + } + } + + /** + * Abort controller if component is render + */ + + componentWillUnmount () { + this.abortController.abort() + } + + /** + * Generates default feature selection creation structure + * @returns Default the default Alert + */ + getDefaultFeatureSelectionProps = (): FeatureSelectionPanelData => { + return { + step: 1, + biomarker: null, + selectedBiomarker: null, + clinicalSource: getDefaultSource(), + mRNASource: getDefaultSource(), + mirnaSource: getDefaultSource(), + methylationSource: getDefaultSource(), + cnaSource: getDefaultSource(), + algorithm: FeatureSelectionAlgorithm.BLIND_SEARCH, + fitnessFunction: FitnessFunction.CLUSTERING, + fitnessFunctionParameters: this.getDefaultFitnessFunctionParameters(), + advancedAlgorithmParameters: this.getDefaultAdvancedAlgorithmParameters(), + crossValidationParameters: { folds: 10 } + } + } + + /** + * Handle changes in the checkedIgnoreProposedAlias value. + * @param checkedIgnoreProposedAlias New checkedIgnoreProposedAlias value. + */ + handleChangeIgnoreProposedAlias = (checkedIgnoreProposedAlias: boolean) => { + // Clear all the proposed molecules as they are not valid anymore (they are computed on search only) + const formBiomarker = this.state.formBiomarker + formBiomarker.moleculesSymbolsFinder.data = [] + + this.setState({ checkedIgnoreProposedAlias, formBiomarker }) + } + + /** + * Generates default settings for advance Algorithm data. + * @returns Default structure of all advance algorithms. + */ + getDefaultAdvancedAlgorithmParameters = (): AdvancedAlgorithmParameters => ({ + isActive: false, + BBHA: { + useSpark: true, + numberOfStars: 60, + numberOfIterations: 10, + BBHAVersion: BBHAVersion.ORIGINAL, + coeff1: 2.2, + coeff2: 0.1 + }, + GA: { + useSpark: true, + numberOfIterations: 10, + populationSize: 50, + mutationRate: 0.01 + }, + coxRegression: { + useSpark: true, + topN: 5 + } + }) + + /** + * Generates default settings for all the fitness functions. + * @returns Default structure for all the fitness functions. + */ + getDefaultFitnessFunctionParameters = (): FitnessFunctionParameters => ({ + clusteringParameters: { ...getDefaultClusteringParameters(), lookForOptimalNClusters: false }, // TODO: Change to default when implemented in backend + svmParameters: getDefaultSvmParameters(), + rfParameters: getDefaultRFParameters() + }) + + /** + * Generates a default alert structure + * @returns Default the default Alert + */ + getDefaultAlertProps = (): CustomAlert => { + return { + message: '', // This have to change during cycle of component + isOpen: false, + type: CustomAlertTypes.SUCCESS, + duration: 500 + } + } + + /** + * Generates a default confirm modal structure + * @returns Default confirmModal object + */ + getDefaultConfirmModal = (): ConfirmModal => { + return { + confirmModal: false, + headerText: '', + contentText: '', + onConfirm: () => console.log('DefaultConfirmModalFunction, this should change during cycle of component') + } + } + + handleCloseStopFSExperiment = () => { + this.setState({ biomarkerToStop: null }) + } + + /** + * Reset the confirm modal, to be used again + */ + handleCloseAlert = () => { + const alert = this.state.alert + alert.isOpen = false + this.setState({ alert }) + } + + /** + * Reset the confirm modal, to be used again + */ + handleCancelConfirmModalState () { + this.setState({ confirmModal: this.getDefaultConfirmModal() }) + } + + /** + * Toggle if advance is active or not + */ + handleSwitchAdvanceAlgorithm = () => { + const featureSelection = this.state.featureSelection + featureSelection.advancedAlgorithmParameters.isActive = !featureSelection.advancedAlgorithmParameters.isActive + this.setState({ featureSelection }) + } + + /** + * Change the value of the advance algorithm and prop selected + * @param advanceAlgorithm Advance algorithm selected + * @param name name of prop to change + * @param value value to set + */ + handleChangeAdvanceAlgorithm = (advanceAlgorithm: string, name: string, value: any) => { + const featureSelection = this.state.featureSelection + featureSelection.advancedAlgorithmParameters[advanceAlgorithm][name] = value + this.setState({ featureSelection }) + } + + /** + * Callback when a new file is selected in the uncontrolled component + * (input type=file) + */ + selectNewFile = () => { this.updateSourceFilenamesAndCommonSamples() } + + /** + * Select the algorithm, initialize the state of the selected and clean the others states + * @param algorithm algorithm selected + */ + handleChangeAlgorithm = (algorithm: FeatureSelectionAlgorithm) => { + const featureSelection = this.state.featureSelection + featureSelection.algorithm = algorithm + this.setState({ featureSelection }) + } + + /** + * Select the algorithm, initialize the state of the selected and clean the others states + * @param fitnessFunction Fitness function selected + */ + handleChangeFitnessFunction = (fitnessFunction: FitnessFunction) => { + const featureSelection = this.state.featureSelection + featureSelection.fitnessFunction = fitnessFunction + this.setState({ featureSelection }) + } + + /** + * Manage changes of Feature Selection process parameters. + * @param fitnessFunction name of fitness function to change + * @param key name of the fitnessFunction object that have changed + * @param value value selected typed depends of what fitness function and key is being changing + */ + handleChangeFitnessFunctionOption = (fitnessFunction: T, key: M, value: FitnessFunctionParameters[T][M]) => { + const featureSelection = this.state.featureSelection + featureSelection.fitnessFunctionParameters[fitnessFunction][key] = value + this.setState({ featureSelection }) + } + + /** + * Manage changes of CrossValidation parameters. + * @param key name of the crossValidationParameters object that have changed. + * @param value value selected. + */ + handleChangeCrossValidation = (key: T, value: any) => { + const featureSelection = this.state.featureSelection + featureSelection.crossValidationParameters[key] = value + this.setState({ featureSelection }) + } + + /** + * Selects a CGDS Study as a source + * @param selectedStudy Selected Study as Source + * @param sourceStateName Source's name in state object to update + */ + selectStudy = (selectedStudy: DjangoCGDSStudy, sourceStateName: SourceStateBiomarker) => { + // Selects source to update + const featureSelection = this.state.featureSelection + const source = featureSelection[sourceStateName] + + source.type = SourceType.CGDS + source.CGDSStudy = selectedStudy + this.setState({ featureSelection }, this.updateSourceFilenamesAndCommonSamples) + } + + /** + * Selects a User's file as a source + * @param selectedFile Selected file as Source + * @param sourceStateName Source's name in state object to update + */ + selectUploadedFile = (selectedFile: DjangoUserFile, sourceStateName: SourceStateBiomarker) => { + // Selects source to update + const featureSelection = this.state.featureSelection + const source = featureSelection[sourceStateName] + + source.type = SourceType.UPLOADED_DATASETS + source.selectedExistingFile = selectedFile + this.setState({ featureSelection }, this.updateSourceFilenamesAndCommonSamples) + } + + /** + * Change the source state to submit a pipeline + * @param sourceType New selected Source + * @param sourceStateName Source's name in state object to update + */ + handleChangeSourceType = (sourceType: SourceType, sourceStateName: SourceStateBiomarker) => { + // Selects source to update + const featureSelection = this.state.featureSelection + const source = featureSelection[sourceStateName] + // Change source type + source.type = sourceType + + // Resets all source values + source.selectedExistingFile = null + source.CGDSStudy = null + cleanRef(source.newUploadedFileRef) + + // After update state + this.setState({ featureSelection }, this.updateSourceFilenamesAndCommonSamples) + } + + /** + * Updates Sources' filenames and common examples counter + */ + updateSourceFilenamesAndCommonSamples = () => { + this.updateSourceFilenames() + // this.checkCommonSamples() TODO: check function and dependencies functions in file Pipeline.tsx + } + + /** + * Handles file input changes to set data to show in form + * IMPORTANT: this is necessary because the file inputs are uncontrolled components + * and doesn't trigger an update of the state fields + */ + updateSourceFilenames = () => { + // Updates state filenames + const featureSelection = this.state.featureSelection + featureSelection.mRNASource.filename = getFilenameFromSource(featureSelection.mRNASource) + featureSelection.clinicalSource.filename = getFilenameFromSource(featureSelection.clinicalSource) + featureSelection.cnaSource.filename = getFilenameFromSource(featureSelection.cnaSource) + featureSelection.methylationSource.filename = getFilenameFromSource(featureSelection.methylationSource) + featureSelection.mirnaSource.filename = getFilenameFromSource(featureSelection.mirnaSource) + this.setState({ featureSelection }) + } + + /** + * Changes confirm modal state + * @param setOption New state of option + * @param headerText Optional text of header in confirm modal, by default will be empty + * @param contentText optional text of content in confirm modal, by default will be empty + * @param onConfirm Modal onConfirm callback + */ + handleChangeConfirmModalState = (setOption: boolean, headerText: string, contentText: string, onConfirm: () => void) => { + const confirmModal = this.state.confirmModal + confirmModal.confirmModal = setOption + confirmModal.headerText = headerText + confirmModal.contentText = contentText + confirmModal.onConfirm = onConfirm + this.setState({ confirmModal }) + } + + /** + * Disambiguate the selected molecule for the yellow buttons. + * @param moleculeToDisambiguate Molecule to disambiguate. + * @param section Molecule section. + * @param selectedOption Selected option. + */ + handleSelectOptionMolecule = (moleculeToDisambiguate: MoleculesSectionData, section: BiomarkerType, selectedOption: string) => { + const formBiomarker = this.state.formBiomarker + const indexToSelect = formBiomarker.moleculesSection[section].data.findIndex((item) => isEqual(item.value, moleculeToDisambiguate.value)) + + // Checks if the molecule is already a valid one + const exists = formBiomarker.moleculesSection[section].data.some((item) => item.value === selectedOption) + const data: MoleculesSectionData[] = formBiomarker.moleculesSection[section].data + data.splice(indexToSelect, 1) + + if (!exists) { + data.push({ + isValid: true, + value: selectedOption + }) + } + + this.setState({ + ...this.state, + formBiomarker: { + ...this.state.formBiomarker, + moleculesSection: { + ...this.state.formBiomarker.moleculesSection, + [section]: { + ...this.state.formBiomarker.moleculesSection[section], + data + } + } + } + }) + } + + /** + * Method that select how the user is going to create a Biomarker + * @param type Select the way to create a Biomarker + */ + handleSelectModal = (type: BiomarkerOrigin) => { + this.setState({ biomarkerTypeSelected: type }) + } + + getBiomarkerFullInstance = (biomarkerSimple: BiomarkerSimple): Promise => { + return new Promise((resolve, reject) => { + this.setState({ loadingFullBiomarkerId: biomarkerSimple.id }) + ky.get(urlBiomarkersCRUD + '/' + biomarkerSimple.id + '/', { signal: this.abortController.signal }).then((response) => { + response.json().then((jsonResponse: Biomarker | PromiseLike) => { + resolve(jsonResponse) + }).catch((err) => { + console.error('Error parsing JSON on Biomarker retrieval:', err) + reject(err) + }) + }).catch((err) => { + console.error('Error getting Biomarker:', err) + + if (!this.abortController.signal.aborted) { + reject(err) + } + }).finally(() => { + if (!this.abortController.signal.aborted) { + this.setState({ loadingFullBiomarkerId: null }) + } + }) + }) + } + + /** + * Opens the modal to show all the Biomarker details. + * @param selectedBiomarker Selected Biomarker instance. + */ + openBiomarkerDetailsModal = (selectedBiomarker: BiomarkerSimple) => { + this.getBiomarkerFullInstance(selectedBiomarker).then((biomarker) => { + this.setState({ + selectedBiomarker: { + ...selectedBiomarker, + cnas: biomarker.cnas, + mirnas: biomarker.mirnas, + methylations: biomarker.methylations, + mrnas: biomarker.mrnas + }, + openDetailsModal: true + }) + }) + } + + /** Closes the modal of Biomarker's details. */ + closeBiomarkerDetailsModal = () => { + this.setState({ + selectedBiomarker: null, + openDetailsModal: false + }) + } + + /** + * Checks if the user can edit the Biomarker (i.e. It was not used for an Inference experiment, Statistical Validation or Trained Model). + * @param biomarker Biomarker to check. + * @returns True if the Biomarker can be edited, false otherwise. + */ + canEditBiomarker = (biomarker: BiomarkerSimple): boolean => !biomarker.was_already_used && biomarker.state === BiomarkerState.COMPLETED + + /** + * Method that select how the user is going to create a Biomarker + * @param selectedBiomarker Biomarker selected to update + */ + handleOpenEditBiomarker = (selectedBiomarker: BiomarkerSimple) => { + this.getBiomarkerFullInstance(selectedBiomarker).then((biomarker) => { + this.setState({ + biomarkerTypeSelected: BiomarkerOrigin.MANUAL, + openCreateEditBiomarkerModal: true, + formBiomarker: { + id: biomarker.id, + canEditMolecules: this.canEditBiomarker(biomarker), + biomarkerName: biomarker.name, + biomarkerDescription: biomarker.description, + tag: biomarker.tag, + moleculeSelected: BiomarkerType.MRNA, + moleculesTypeOfSelection: MoleculesTypeOfSelection.INPUT, + moleculesSection: { + [BiomarkerType.CNA]: { + isLoading: false, + data: biomarker.cnas.map(item => ({ isValid: true, value: item.identifier })) + }, + [BiomarkerType.MIRNA]: { + isLoading: false, + data: biomarker.mirnas.map(item => ({ isValid: true, value: item.identifier })) + }, + [BiomarkerType.METHYLATION]: { + isLoading: false, + data: biomarker.methylations.map(item => ({ isValid: true, value: item.identifier })) + }, + [BiomarkerType.MRNA]: { + isLoading: false, + data: biomarker.mrnas.map(item => ({ isValid: true, value: item.identifier })) + } + }, + validation: { + haveAmbiguous: false, + haveInvalid: false, + isLoading: false, + checkBox: false + }, + moleculesSymbolsFinder: { + isLoading: false, + data: [] + } + } + }) + }) + } + + handleSelectAllBiomarker = () => { + const { experimentInfo } = this.props + + const allMolecules = experimentInfo.rows + + this.setState({ + biomarkerTypeSelected: BiomarkerOrigin.MANUAL, + openCreateEditBiomarkerModal: true, + formBiomarker: { + id: null, + canEditMolecules: true, + biomarkerName: '', + biomarkerDescription: '', + tag: '', + moleculeSelected: BiomarkerType.MRNA, + moleculesTypeOfSelection: MoleculesTypeOfSelection.INPUT, + moleculesSection: { + [BiomarkerType.CNA]: { isLoading: false, data: [] }, + [BiomarkerType.MIRNA]: { + isLoading: false, + data: allMolecules.map(item => ({ + isValid: true, + value: item.gem + })) + }, + [BiomarkerType.METHYLATION]: { isLoading: false, data: [] }, + [BiomarkerType.MRNA]: { + isLoading: false, + data: allMolecules.map(item => ({ + isValid: true, + value: item.gene + })) + } + }, + validation: { + haveAmbiguous: false, + haveInvalid: false, + isLoading: false, + checkBox: false + }, + moleculesSymbolsFinder: { + isLoading: false, + data: [] + } + } + }) + } + + buildFormBiomarkerFromCurrentPageFilteredRows = () => { + const filteredRows = this.getFilteredMoleculesCurrentPage() + const { experimentInfo, tableControl } = this.props + + // Verify if there are filters applied + const hasFilters = Object.values(tableControl.filters).some(f => f.value !== null && f.value !== undefined && f.value !== '') + + // If there are filters applied, use the filtered rows, otherwise use all the rows + const rowsToUse = hasFilters ? filteredRows : experimentInfo.rows + + const genes = rowsToUse.map(r => r.gene) + const gems = rowsToUse.map(r => r.gem) + + return { + ...this.getDefaultFormBiomarker(), + moleculesSection: { + [BiomarkerType.MRNA]: { isLoading: false, data: genes.map(g => ({ value: g, isValid: true })) }, + [BiomarkerType.MIRNA]: { isLoading: false, data: gems.map(g => ({ value: g, isValid: true })) }, + [BiomarkerType.CNA]: { isLoading: false, data: [] }, + [BiomarkerType.METHYLATION]: { isLoading: false, data: [] } + } + } + } + + getFilteredMoleculesCurrentPage = (): DjangoMRNAxGEMResultRow[] => { + const { experimentInfo, tableControl } = this.props + const rows = experimentInfo.rows + + return rows.filter(row => { + return Object.entries(tableControl.filters).every(([key, filter]) => { + const filterValue = filter.value + + if (filterValue === null || filterValue === undefined || filterValue === '') { return true } + + return row[key as keyof DjangoMRNAxGEMResultRow] === filterValue + }) + }) + } + + /** + * Method that get symbols while user is writing in Select molecules input + * @param query string that is sending to the api + */ + handleGenesSymbolsFinder = (query: string): void => { + // loading aca + const formBiomarkerPreLoad = this.state.formBiomarker + formBiomarkerPreLoad.moleculesSymbolsFinder.isLoading = true + let urlToFind = urlGeneSymbolsFinder + + switch (this.state.formBiomarker.moleculeSelected) { + case BiomarkerType.MIRNA: + urlToFind = urlMiRNACodesFinder + break + case BiomarkerType.METHYLATION: + urlToFind = urlMethylationSitesFinder + break + default: + break + } + + this.setState({ formBiomarker: formBiomarkerPreLoad }) + ky.get(urlToFind, { searchParams: { query, limit: 5 }, signal: this.abortController.signal, timeout: REQUEST_TIMEOUT }).then((response) => { + response.json().then((jsonResponse: MoleculeFinderResult[]) => { + const formBiomarker = this.state.formBiomarker + const checkedIgnoreProposedAlias = this.state.checkedIgnoreProposedAlias // For short + + formBiomarker.moleculesSymbolsFinder.data = jsonResponse.map(molecule => { + const text = checkedIgnoreProposedAlias || molecule.molecule === molecule.standard + ? molecule.molecule + : `${molecule.molecule} (${molecule.standard})` + + return { + key: molecule.molecule, + text, + value: checkedIgnoreProposedAlias ? molecule.molecule : molecule.standard + } + }) + this.setState({ formBiomarker }) + }).catch((err) => { + console.error('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.error('Error getting genes ->', err) + }).finally(() => { + if (!this.abortController.signal.aborted) { + const formBiomarker = this.state.formBiomarker + formBiomarker.moleculesSymbolsFinder.isLoading = false + this.setState({ formBiomarker }) + } + }) + } + + /** + * Method that removes invalid genes of the sector selected + * @param sector string of the sector selected to change state + */ + handleRemoveInvalidGenes = (sector: BiomarkerType): void => { + this.setState({ + ...this.state, + formBiomarker: { + ...this.state.formBiomarker, + moleculesSection: { + ...this.state.formBiomarker.moleculesSection, + [sector]: { + ...this.state.formBiomarker.moleculesSection[sector], + data: this.state.formBiomarker.moleculesSection[sector].data.filter(gen => gen.isValid || Array.isArray(gen.value)) + } + } + } + }) + } + + /** + * Method that removes all the molecules of the sector selecterd + * @param sector string of the sector selected to change state + */ + handleRestartSection = (sector: BiomarkerType): void => { + this.setState({ + ...this.state, + formBiomarker: { + ...this.state.formBiomarker, + moleculesSection: { + ...this.state.formBiomarker.moleculesSection, + [sector]: { + ...this.state.formBiomarker.moleculesSection[sector], + data: [] + } + } + } + }) + } + + /** + * Order data to show in the section. + * @param data Data to order. + * @returns Ordered data. + */ + orderData = (data: MoleculesSectionData[]): MoleculesSectionData[] => { + return data.sort((a, b) => { + const cond = Number(a.isValid) - Number(b.isValid) + + if (cond !== 0) { + return cond + } + + return Array.isArray(a.value) ? 1 : -1 + }) + } + + /** + * Sets a list of molecules to the current selected section. + * @param moleculesList List of molecules to set. + */ + setMoleculesToSelectedSection = (moleculesList: MoleculesSectionData[]) => { + const moleculeTypeSelected = this.state.formBiomarker.moleculeSelected + + // Sets loading in false + const moleculesSection = { + ...this.state.formBiomarker.moleculesSection, + [moleculeTypeSelected]: { + isLoading: false, + data: this.orderData([...this.state.formBiomarker.moleculesSection[moleculeTypeSelected].data].concat(moleculesList)) + } + } + + const newFormBiomarker: FormBiomarkerData = { + ...this.state.formBiomarker, + moleculesSection + } + + newFormBiomarker.moleculesSymbolsFinder.isLoading = false + newFormBiomarker.moleculesSection[moleculeTypeSelected].isLoading = false + + this.setState({ formBiomarker: newFormBiomarker }) + } + + /** + * Method that gets symbols while user is writing in Select molecules input + * @param molecules array of strings that is sending to the api + */ + handleGeneSymbols = async (molecules: string[]): Promise => { + const moleculesSectionPreload = { + ...this.state.formBiomarker.moleculesSection, + [this.state.formBiomarker.moleculeSelected]: { + isLoading: true, + data: [...this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data] + } + } + this.setState({ + formBiomarker: { + ...this.state.formBiomarker, + moleculesSymbolsFinder: { + ...this.state.formBiomarker.moleculesSymbolsFinder, + isLoading: true + }, + moleculesSection: moleculesSectionPreload + } + }) + let urlToFind: string + let json: { [key: string]: string[] } + let keyMolecules: string + + switch (this.state.formBiomarker.moleculeSelected) { + case BiomarkerType.MIRNA: + urlToFind = urlMiRNACodes + json = { mirna_codes: molecules } + keyMolecules = 'mirna_codes' + break + case BiomarkerType.METHYLATION: + urlToFind = urlMethylationSites + json = { methylation_sites: molecules } + keyMolecules = 'methylation_sites' + break + default: + urlToFind = urlGeneSymbols + json = { gene_ids: molecules } + keyMolecules = 'gene_ids' + break + } + + const genesArray: MoleculesSectionData[] = [] + ky.post(urlToFind, { headers: getDjangoHeader(), json, timeout: REQUEST_TIMEOUT }).then((response) => { + response.json().then((jsonResponse: { [key: string]: string[] }) => { + const genes = Object.entries(jsonResponse) + + for (const gene of genes) { + let condition + + switch (gene[1].length) { + case 0: + condition = this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data.concat(genesArray).filter(item => item.value === gene[0]) + + if (!condition.length) { + genesArray.push({ + isValid: false, + value: gene[0] + }) + } + + break + case 1: + condition = this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data.concat(genesArray).filter(item => item.value === gene[1][0]) + + if (!condition.length) { + genesArray.push({ + isValid: true, + value: gene[1][0] + }) + } + + break + default: + condition = this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data.concat(genesArray).filter( + item => isEqual(item.value, gene[1]) + ) + + if (!condition.length) { + genesArray.push({ + isValid: false, + value: gene[1] + }) + } + + break + } + } + }).catch((err) => { + console.error('Error parsing JSON ->', err) + console.warn('Setting all molecules as invalid to show warning') + + json[keyMolecules].forEach(molecule => { + genesArray.push({ + isValid: false, + value: molecule + }) + }) + }).finally(() => { + this.setMoleculesToSelectedSection(genesArray) + }) + }).catch((err) => { + console.error('Error getting molecules ->', err) + console.warn('Setting all molecules as invalid to show warning') + + json[keyMolecules].forEach(molecule => { + genesArray.push({ + isValid: false, + value: molecule + }) + }) + }).finally(() => { + this.setMoleculesToSelectedSection(genesArray) + }) + } + + /** + * Generates a default formBiomarker + * @returns Default FormBiomarkerData object + */ + getDefaultFormBiomarker (): FormBiomarkerData { + return { + id: null, + biomarkerName: '', + biomarkerDescription: '', + canEditMolecules: true, + tag: null, + moleculeSelected: BiomarkerType.MRNA, + moleculesTypeOfSelection: MoleculesTypeOfSelection.INPUT, + validation: { + haveAmbiguous: false, + haveInvalid: false, + isLoading: false, + checkBox: false + }, + moleculesSection: { + [BiomarkerType.CNA]: { + isLoading: false, + data: [] + }, + [BiomarkerType.MIRNA]: { + isLoading: false, + data: [] + }, + [BiomarkerType.METHYLATION]: { + isLoading: false, + data: [] + }, + [BiomarkerType.MRNA]: { + isLoading: false, + data: [] + } + }, + moleculesSymbolsFinder: { + isLoading: false, + data: [] + } + } + } + + /** + * Updates checkbox status + * @param value new value to set + */ + handleChangeCheckBox = (value: boolean) => { + const formBiomarker = this.state.formBiomarker + formBiomarker.validation.checkBox = value + this.setState({ formBiomarker }) + } + + /** + * Validates if the form is correct, if not change state of labels alerts bars + * @returns Some flags indicating if the form is valid or not + */ + handleValidateForm = (): ValidationForm => { + let haveAmbiguous = false + let haveInvalid = false + + for (const option of Object.values(BiomarkerType)) { + if (!haveAmbiguous) { + const indexOfAmbiguous = this.state.formBiomarker.moleculesSection[option].data.findIndex(item => !item.isValid && Array.isArray(item.value)) + + if (indexOfAmbiguous >= 0) { + haveAmbiguous = true + } + } + + if (!haveInvalid && !this.state.formBiomarker.validation.checkBox) { + const indexOfInvalid = this.state.formBiomarker.moleculesSection[option].data.findIndex(item => !item.isValid && !Array.isArray(item.value)) + + if (indexOfInvalid >= 0) { + haveInvalid = true + } + } + } + + return { + haveAmbiguous, + haveInvalid + } + } + + /** + * Checks if it's a valid structure to send the molecule to backend and create the Biomarker. + * @param item Molecule to send. + * @returns True if it's valid, false if not. + */ + moleculeIdentifierIsValid = (item: MoleculesSectionData): boolean => !Array.isArray(item.value) && item.isValid + + /** + * Generates a valid structure to send the molecule to backend and create the Biomarker + * @param item Molecule to send + * @returns Correct structure to send + */ + moleculeIdentified = (item: MoleculesSectionData): SaveMoleculeStructure => ({ + identifier: item.value as string + }) + + /** + * Generates a valid structure to send the molecules to backend and create the Biomarker checking if the + * "Ignore errors" checkbox is checked or not. + * @param molecules Molecules to send. + * @returns Correct structure to send. + */ + getMoleculesData = (molecules: MoleculesSectionData[]): SaveMoleculeStructure[] => { + const ignoreErrors = this.state.formBiomarker.validation.checkBox + + if (ignoreErrors) { + return molecules.map(this.moleculeIdentified) + } else { + return molecules.filter(this.moleculeIdentifierIsValid).map(this.moleculeIdentified) + } + } + + /** + * Makes the request to create a Biomarker + */ + handleSendForm = () => { + const formBiomarker = this.state.formBiomarker + formBiomarker.validation.isLoading = true + this.setState({ formBiomarker }) + + // Gets name and description + const simpleBiomarker: BiomarkerNameAndDesc = { + name: formBiomarker.biomarkerName, + description: formBiomarker.biomarkerDescription + } + + // Adds molecules if needed + const biomarkerToSend: SaveBiomarkerStructure | BiomarkerNameAndDesc = formBiomarker.canEditMolecules + ? { + ...simpleBiomarker, + mrnas: this.getMoleculesData(formBiomarker.moleculesSection.mRNA.data), + mirnas: this.getMoleculesData(formBiomarker.moleculesSection.miRNA.data), + cnas: this.getMoleculesData(formBiomarker.moleculesSection.CNA.data), + methylations: this.getMoleculesData(formBiomarker.moleculesSection.Methylation.data) + } + : simpleBiomarker + + const settings: Options = { + headers: getDjangoHeader(), + json: biomarkerToSend, + timeout: REQUEST_TIMEOUT + } + + // Checks if it's a creation or an update + if (!formBiomarker.id) { + ky.post(urlBiomarkersCreate, settings).then((response) => { + response.json().then((_jsonResponse: Biomarker) => { + this.closeModalWithSuccessMsg('Biomarker created successfully') + }).catch((err) => { + console.log('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.log('Error adding Biomarker ->', err) + const alert = this.state.alert + alert.isOpen = true + alert.type = CustomAlertTypes.ERROR + alert.message = 'Error creating biomarker!' + this.setState({ alert }) + }).finally(() => { + formBiomarker.validation.isLoading = false + this.setState({ formBiomarker }) + }) + } else { + const url = formBiomarker.canEditMolecules ? urlBiomarkersCRUD : urlBiomarkersSimpleUpdate + ky.patch(`${url}/${formBiomarker.id}/`, settings).then((response) => { + response.json().then((_jsonResponse: Biomarker) => { + this.closeModalWithSuccessMsg('Biomarker edited successfully') + }).catch((err) => { + console.log('Error parsing JSON ->', err) + }) + }).catch((err) => { + console.log('Error getting genes ->', err) + const alert = this.state.alert + alert.isOpen = true + alert.type = CustomAlertTypes.ERROR + alert.message = 'Error editing biomarker!' + this.setState({ alert }) + }).finally(() => { + formBiomarker.validation.isLoading = false + this.setState({ formBiomarker }) + }) + } + } + + /** + * change name or description of manual form + * @param value new value for input form + * @param name type of input to change + */ + handleChangeInputForm = (value: string, name: 'biomarkerName' | 'biomarkerDescription') => { + const formBiomarker = this.state.formBiomarker + formBiomarker[name] = value + this.setState({ formBiomarker }) + } + + /** + * Handles the table's control filters, select, etc changes + * @param value Value to set to the state moleculeSelected in formBiomarkerState + */ + handleChangeMoleculeSelected = (value: BiomarkerType) => { + const formBiomarker = this.state.formBiomarker + formBiomarker.moleculeSelected = value + formBiomarker.moleculesSymbolsFinder.data = [] + this.setState({ + formBiomarker + }) + } + + /** + * Handles the table's control filters, select, etc changes + * @param value Value to set to the state moleculesTypeOfSelection in formBiomarkerState + */ + handleChangeMoleculeInputSelected = (value: MoleculesTypeOfSelection) => { + this.setState({ + formBiomarker: { + ...this.state.formBiomarker, + moleculesTypeOfSelection: value + } + }) + } + + /** + * Handles the table's control filters, select, etc changes + * @param value Value to add to the molecules section that is selected + */ + handleAddMoleculeToSection = (value: MoleculesSectionData) => { + const genesSymbolsFinder = this.state.formBiomarker.moleculesSymbolsFinder + genesSymbolsFinder.data = [] + this.setState({ + formBiomarker: { + ...this.state.formBiomarker, + moleculesSymbolsFinder: genesSymbolsFinder + } + }) + + const sectionFound = this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data.find((item: MoleculesSectionData) => value.value === item.value) + + if (sectionFound !== undefined) { + return + } + + const moleculesSection = { + ...this.state.formBiomarker.moleculesSection, + [this.state.formBiomarker.moleculeSelected]: { + isLoading: false, + data: [...this.state.formBiomarker.moleculesSection[this.state.formBiomarker.moleculeSelected].data, value] + } + } + this.setState({ + formBiomarker: { + ...this.state.formBiomarker, + moleculesSection + } + }) + } + + /** + * Handles the table's control filters, select, etc changes + * @param section Value to add to the molecules section that is selected + * @param molecule molecule to remove of the array + */ + handleRemoveMolecule = (section: BiomarkerType, molecule: MoleculesSectionData) => { + // keeps the molecules that are not the one that is going to be removed + const data = this.state.formBiomarker.moleculesSection[section].data.filter((item: MoleculesSectionData) => { + return item.value !== molecule.value + }) + + this.setState({ + ...this.state, + formBiomarker: { + ...this.state.formBiomarker, + moleculesSection: { + ...this.state.formBiomarker.moleculesSection, + [section]: { + isLoading: false, + data + } + } + } + }) + } + + /** + * Generates a default new file form + * @returns An object with all the field with default values + */ + getDefaultNewBiomarker (): Biomarker { + return { + id: null, + name: '', + description: '', + tag: null, + number_of_mrnas: 0, + number_of_mirnas: 0, + number_of_cnas: 0, + number_of_methylations: 0, + has_fs_experiment: false, + was_already_used: false, + origin: BiomarkerOrigin.BASE, + state: BiomarkerState.COMPLETED, + contains_nan_values: false, + column_used_as_index: '', + methylations: [], + mirnas: [], + cnas: [], + mrnas: [], + user: { + id: 0, + username: '' + }, + is_public: false, + } + } + + /** + * Cleans the new/edit biomarker form + */ + cleanForm = () => { + this.setState({ + openCreateEditBiomarkerModal: true, + formBiomarker: this.getDefaultFormBiomarker(), + confirmModal: this.getDefaultConfirmModal() + }) + } + + /** + * Show a modal to confirm a Biomarker deletion + * @param biomarker Selected Biomarker to delete + */ + confirmBiomarkerDeletion = (biomarker: BiomarkerSimple) => { + this.setState({ + selectedBiomarkerToDeleteOrSync: biomarker, + showDeleteBiomarkerModal: true + }) + } + + /** Closes the deletion confirm modals. */ + handleClose = () => { + this.setState({ showDeleteBiomarkerModal: false }) + } + + /** + * Check if can submit the new Biomarker form + * @returns True if everything is OK, false otherwise + */ + canSubmitBiomarkerForm = (): boolean => { + return !this.state.addingOrEditingBiomarker && + this.state.newBiomarker.name.trim().length > 0 + } + + /** + * Handles Biomarker form changes + * @param name Name of the state field to modify + * @param value Value to set to the state field + */ + handleFormChanges = (name: string, value) => { + const newBiomarker = this.state.newBiomarker + newBiomarker[name] = value + this.setState({ newBiomarker }) + } + + /** + * TODO: Check if needed + * Adds a Survival data tuple for a CGDSDataset + * @param datasetName Name of the edited CGDS dataset + */ + addSurvivalFormTuple = (datasetName: NameOfCGDSDataset) => { + const newBiomarker = this.state.newBiomarker + const dataset = newBiomarker[datasetName] + + if (dataset !== null) { + const newElement: DjangoSurvivalColumnsTupleSimple = { event_column: '', time_column: '' } + + if (dataset.survival_columns === undefined) { + dataset.survival_columns = [] + } + + dataset.survival_columns.push(newElement) + this.setState({ newBiomarker }) + } + } + + /** + * TODO: Check if needed + * Removes a Survival data tuple for a CGDSDataset + * @param datasetName Name of the edited CGDS dataset + * @param idxSurvivalTuple Index in survival tuple + */ + removeSurvivalFormTuple = (datasetName: NameOfCGDSDataset, idxSurvivalTuple: number) => { + const newBiomarker = this.state.newBiomarker + const dataset = newBiomarker[datasetName] + + if (dataset !== null && dataset.survival_columns !== undefined) { + dataset.survival_columns.splice(idxSurvivalTuple, 1) + this.setState({ newBiomarker }) + } + } + + /** + * TODO: Check if needed + * Handles CGDS Dataset form changes in fields of Survival data tuples + * @param datasetName Name of the edited CGDS dataset + * @param idxSurvivalTuple Index in survival tuple + * @param name Field of the CGDS dataset to change + * @param value Value to assign to the specified field + */ + handleSurvivalFormDatasetChanges = ( + datasetName: NameOfCGDSDataset, + idxSurvivalTuple: number, + name: string, + value: any + ) => { + const newBiomarker = this.state.newBiomarker + const dataset = newBiomarker[datasetName] + + if (dataset !== null && dataset.survival_columns !== undefined) { + dataset.survival_columns[idxSurvivalTuple][name] = value + this.setState({ newBiomarker }) + } + } + + /** + * Checks if the form is entirely empty. Useful to enable 'Cancel' button + * @returns True is any of the form's field contains any data. False otherwise + */ + isFormEmpty = (): boolean => isEqual(this.state.formBiomarker, this.getDefaultFormBiomarker()) + + /** + * Callback to mark a Biomarker as selected + * @param biomarker Selected biomarker to mark + */ + markBiomarkerAsSelected = (biomarker: Biomarker) => { + const featureSelection = this.state.featureSelection + featureSelection.selectedBiomarker = biomarker + this.setState({ featureSelection }) + } + + /** + * Function to complete step 2 + */ + handleCompleteStep2 = () => { + const featureSelection = this.state.featureSelection + featureSelection.step = 3 + this.setState({ featureSelection }) + } + + /** + * Function to go back to step 1 + */ + handleGoBackStep1 = () => { + const featureSelection = this.state.featureSelection + featureSelection.clinicalSource = getDefaultSource() + featureSelection.mRNASource = getDefaultSource() + featureSelection.mirnaSource = getDefaultSource() + featureSelection.methylationSource = getDefaultSource() + featureSelection.cnaSource = getDefaultSource() + featureSelection.step = 1 + this.setState({ featureSelection }) + } + + /** Closes the modal to confirm a Biomarker cloning. */ + closeModalToClone = () => { this.setState({ biomarkerToClone: null }) } + + /** + * Function to go back to step 2 + */ + handleGoBackStep2 = () => { + const featureSelection = this.state.featureSelection + featureSelection.step = 2 + featureSelection.algorithm = FeatureSelectionAlgorithm.BLIND_SEARCH + this.setState({ featureSelection }) + } + + handleConfirm = () => { + const { selectedOption } = this.state + + this.setState({ openSelectOptionModal: false }, () => { + if (selectedOption === 'selectAll') { + // ver de si hacer un endpoint que traiga todos los genes y no solo los de la pagina + this.handleSelectAllBiomarker() + } else { + // Select with Filters: usamos solo la página actual y filtros + this.setState({ + openCreateEditBiomarkerModal: true, + formBiomarker: this.buildFormBiomarkerFromCurrentPageFilteredRows() + }) + } + }) + } + + /** + * Closes the modal and shows a successful Semantic-UI Alert message. + * @param msg Message to show. + */ + closeModalWithSuccessMsg = (msg: string) => { + const alert = this.state.alert + alert.isOpen = true + alert.type = CustomAlertTypes.SUCCESS + alert.message = msg + this.setState({ + alert, + formBiomarker: this.getDefaultFormBiomarker(), + openCreateEditBiomarkerModal: false, + confirmModal: this.getDefaultConfirmModal(), + biomarkerTypeSelected: BiomarkerOrigin.BASE + }) + } + + /** + * Generates default table's Filters. + * @returns Default object for table's Filters + */ + getDefaultFilters (): PaginationCustomFilter[] { + const tagOptions: DropdownItemProps[] = this.state.tags.map((tag) => { + const id = tag.id as number + return { key: id, value: id, text: tag.name } + }) + + tagOptions.unshift({ key: 'no_tag', text: 'No tag' }) + + // TODO: refactor Tag key as it's the same as AllExperimentsView.tsx and UserFilesView.tsx + return [ + { label: 'Tag', keyForServer: 'tag', defaultValue: '', placeholder: 'Select an existing Tag', options: tagOptions, width: 3 } + ] + } + + closeBiomarkerModal = () => { + this.setState({ + formBiomarker: this.getDefaultFormBiomarker(), + featureSelection: this.getDefaultFeatureSelectionProps(), + openCreateEditBiomarkerModal: false, + confirmModal: this.getDefaultConfirmModal(), + biomarkerTypeSelected: BiomarkerOrigin.BASE + }) + } + + buildFormBiomarkerFromFilteredRows = () => { + const { rows } = this.props.experimentInfo + + return { + ...this.getDefaultFormBiomarker(), + moleculesSection: { + [BiomarkerType.MRNA]: { + isLoading: false, + data: rows.map(item => ({ value: item.gene, isValid: true })) + }, + [BiomarkerType.MIRNA]: { + isLoading: false, + data: rows.map(item => ({ value: item.gem, isValid: true })) + }, + // los otros tipos vacíos + [BiomarkerType.CNA]: { isLoading: false, data: [] }, + [BiomarkerType.METHYLATION]: { isLoading: false, data: [] } + } + } + } + + render () { + const { openSelectOptionModal, selectedOption } = this.state + return ( + <> + this.setState({ + formBiomarker: this.getDefaultFormBiomarker(), + openSelectOptionModal: true + })} + /> + + {/* Modal con opciones */} + this.setState({ openSelectOptionModal: false })} + > + Create Biomarker + +

Select how you want to build the biomarker:

+
+ + this.setState({ selectedOption: value as SelectedOption })} + /> + this.setState({ selectedOption: value as SelectedOption })} + /> + +
+
+ + + + +
+ + {/* Modal con ManualForm */} + } + closeOnEscape={false} + closeOnDimmerClick={false} + closeOnDocumentClick={false} + className='space-modal large-modal' + onClose={() => this.closeBiomarkerModal()} + > + + + + ) + } +} diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx new file mode 100644 index 00000000..f04f7445 --- /dev/null +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react' +import { Form, Modal, Button, Icon, ButtonProps } from 'semantic-ui-react' +import { ExperimentInfo } from '../../../utils/interfaces' + +type BiomarkerOption = 'selectAll' | 'selectWithFilters' + +interface CreateBiomarkerButtonProps extends Partial { + experimentInfo: ExperimentInfo + onCreateBiomarker: (options: { + experimentInfo: ExperimentInfo; + selectAll: boolean; + }) => void; +} + +const CreateBiomarkerButton: React.FC = ({ + experimentInfo, + onCreateBiomarker, + ...buttonProps // <-- todas las props extra van acá +}) => { + const [open, setOpen] = useState(false) + const [selectedOption, setSelectedOption] = useState('selectAll') + + const handleConfirm = () => { + onCreateBiomarker({ + experimentInfo, + selectAll: selectedOption === 'selectAll', + }) + setOpen(false) + } + + return ( + <> + {/* Botón que abre el modal */} + + + {/* Modal con opciones */} + setOpen(false)} + > + Create Biomarker + +

Select how you want to build the biomarker:

+
+ + setSelectedOption(value as BiomarkerOption)} + /> + setSelectedOption(value as BiomarkerOption)} + /> + +
+
+ + + + +
+ + ) +} + +export default CreateBiomarkerButton diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx index 3f1fd12c..d9cbcba7 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx @@ -5,6 +5,7 @@ import { InfoPopup } from './gene-gem-details/InfoPopup' import { generatesOrderingQueryMultiField } from '../../../utils/util_functions' import { DjangoExperiment } from '../../../utils/django_interfaces' import { SingleRangeSlider } from 'neo-react-semantic-ui-range' +import { BiomarkerFromCorrelationModal } from './BiomarkerFromCorrelationModal' declare const urlDownloadResultWithFilters: string @@ -29,9 +30,9 @@ interface ResultTableControlFormProps { /** Callback for changes in (adjusted) p-values precision only */ changePrecisionState: (showHighPrecision: boolean) => void, /** Callback for reset filters and sorting */ - resetFiltersAndSorting: (experiment: DjangoExperiment) => void + resetFiltersAndSorting: (experiment: DjangoExperiment) => void, /** Callback for reset only filters */ - resetFilters: (experiment: ExperimentInfo) => void + resetFilters: (experiment: ExperimentInfo) => void, } /** @@ -70,140 +71,152 @@ export const ResultTableControlForm = (props: ResultTableControlFormProps) => { } return ( -
- - {/* Number of showing/total combinations */} - - - - - - {props.numberOfShowingCombinations} / {props.totalNumberOfCombinations} - - - SHOWING/TOTAL - - - - - - {/* mRNA/MiRNA search */} - props.onHandleTableControlChanges(name, value)} - /> - - {/* Correlation threshold */} - - - - props.onHandleTableControlChanges('coefficientThreshold', value)} - /> - - - - - - {/* Correlation type */} - props.onHandleTableControlChanges(name, value)} - /> - - {/* Page size */} - props.onHandleTableControlChanges(name, value)} - /> - - props.changePrecisionState(!isShowingHighPrecision)} - /> - - props.resetFilters(props.experimentInfo)} - /> - - props.resetFiltersAndSorting(props.experimentInfo.experiment)} - /> - - window.open(generateDownloadWithFiltersQuery(), '_blank')} - disabled={!props.experimentInfo.rows.length} - /> - - - - This table shows all the combinations whose correlation coefficient was more or equal than selected threshold ({props.minimumCoefficientThreshold}). Choose some of the options listed in Actions column -

- )} - /> -
-
-
+ <> +
+ +
+ + {/* Number of showing/total combinations */} + + + + + + {props.numberOfShowingCombinations} / {props.totalNumberOfCombinations} + + + SHOWING/TOTAL + + + + + + {/* mRNA/MiRNA search */} + props.onHandleTableControlChanges(name, value)} + className='no-margin-right-form-field' + /> + + {/* Correlation threshold */} + + + + props.onHandleTableControlChanges('coefficientThreshold', value)} + /> + + + + + + {/* Correlation type */} + props.onHandleTableControlChanges(name, value)} + className='no-margin-right-form-field' + /> + + {/* Page size */} + props.onHandleTableControlChanges(name, value)} + className='no-margin-right-form-field' + /> + + props.changePrecisionState(!isShowingHighPrecision)} + /> + + { + console.log('filtros del biomarker', { + selectAll, + experimentInfo, + tableControl + }) + }} + /> + + props.resetFiltersAndSorting(props.experimentInfo.experiment)} + /> + + window.open(generateDownloadWithFiltersQuery(), '_blank')} + disabled={!props.experimentInfo.rows.length} + /> + + + + This table shows all the combinations whose correlation coefficient was more or equal than selected threshold ({props.minimumCoefficientThreshold}). Choose some of the options listed in Actions column +

+ )} + /> +
+
+
+
+ + ) } diff --git a/src/frontend/static/frontend/src/css/gem.css b/src/frontend/static/frontend/src/css/gem.css index 27c3edfb..9ba6b3b1 100644 --- a/src/frontend/static/frontend/src/css/gem.css +++ b/src/frontend/static/frontend/src/css/gem.css @@ -123,7 +123,10 @@ } .no-margin-right-form-field * { - margin-right: 0 !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + align-items: center; } #sorting-order-data { From ec71d8d1254079e6669e219d52901ec652fb2a85 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Sun, 15 Feb 2026 20:22:38 -0300 Subject: [PATCH 02/16] Fix bugs in obtaining filtered and unfiltered results --- .../BiomarkerFromCorrelationModal.tsx | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx index 5fd6b445..9453b86b 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -85,7 +85,8 @@ interface BiomarkerFromCorrelationModalState { submittingFSExperiment: boolean, openDetailsModal2: boolean, selectedOption: SelectedOption, - openSelectOptionModal: boolean + openSelectOptionModal: boolean, + experimentInfoWithoutFilters: ExperimentInfo } /** @@ -120,7 +121,11 @@ export class BiomarkerFromCorrelationModal extends React.Component { - const { experimentInfo } = this.props - - const allMolecules = experimentInfo.rows + const allMolecules = this.state.experimentInfoWithoutFilters.rows + console.log('handleSelectAllBiomarker Filtrado', this.props.experimentInfo) + console.log('handleSelectAllBiomarker Sin filtrar', allMolecules) this.setState({ biomarkerTypeSelected: BiomarkerOrigin.MANUAL, openCreateEditBiomarkerModal: true, @@ -1215,12 +1220,7 @@ export class BiomarkerFromCorrelationModal extends React.Component this.closeBiomarkerModal()} > + ) } From 72a7c1d4ea44d64630d8dba297147352dbabdad4 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Sun, 15 Feb 2026 20:23:40 -0300 Subject: [PATCH 03/16] Normalization --- .../pipeline/experiment-result/CreateBiomarkerButton.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx index f04f7445..a7a29ae7 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx @@ -40,7 +40,7 @@ const CreateBiomarkerButton: React.FC = ({ className='space-modal large-modal' labelPosition='left' onClick={() => setOpen(true)} - {...buttonProps} // <-- se propagan las props aquí + {...buttonProps} > Create Biomarker @@ -49,7 +49,8 @@ const CreateBiomarkerButton: React.FC = ({ {/* Modal con opciones */} setOpen(false)} > Create Biomarker From 39f052827c459b5c48e8f25c70dcd2fc11fe1e0f Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Sun, 15 Feb 2026 20:28:58 -0300 Subject: [PATCH 04/16] Normalization --- .../experiment-result/BiomarkerFromCorrelationModal.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx index 9453b86b..2189b025 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -570,8 +570,6 @@ export class BiomarkerFromCorrelationModal extends React.Component { const allMolecules = this.state.experimentInfoWithoutFilters.rows - console.log('handleSelectAllBiomarker Filtrado', this.props.experimentInfo) - console.log('handleSelectAllBiomarker Sin filtrar', allMolecules) this.setState({ biomarkerTypeSelected: BiomarkerOrigin.MANUAL, openCreateEditBiomarkerModal: true, From 7fb322167a2b5c15bee7917bb2e2c7c89fad6e4f Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Sun, 15 Feb 2026 22:13:31 -0300 Subject: [PATCH 05/16] fix bugs in biomarker insertions --- .../BiomarkerFromCorrelationModal.tsx | 41 +++++++++++++++++-- .../CreateBiomarkerButton.tsx | 5 +-- .../ResultTableControlForm.tsx | 8 +--- src/frontend/templates/frontend/gem.html | 9 ++++ src/frontend/views.py | 24 ++++++++++- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx index 2189b025..e11a536d 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -1,6 +1,6 @@ import React from 'react' // Update the import path to the correct location of Base component -import { Modal, DropdownItemProps, Icon, Form, Button } from 'semantic-ui-react' +import { Modal, DropdownItemProps, Icon, Form, Button, Confirm } from 'semantic-ui-react' import { DjangoCGDSStudy, DjangoMRNAxGEMResultRow, DjangoSurvivalColumnsTupleSimple, DjangoTag, DjangoUserFile } from '../../../utils/django_interfaces' import ky, { Options } from 'ky' import { getDjangoHeader, cleanRef, getFilenameFromSource, getDefaultSource } from '../../../utils/util_functions' @@ -10,8 +10,10 @@ import { ManualForm } from '../../biomarkers/modalContentBiomarker/manualForm/Ma import { PaginationCustomFilter } from '../../common/PaginatedTable' import { isEqual } from 'lodash' import { getDefaultClusteringParameters, getDefaultRFParameters, getDefaultSvmParameters } from '../../biomarkers/utils' +import { BiomarkerDetailsModal } from '../../biomarkers/BiomarkerDetailsModal' +import { Alert } from '../../common/Alert' -// URLs defined in biomarkers.html +// URLs defined in gem.html declare const urlBiomarkersCRUD: string declare const urlBiomarkersSimpleUpdate: string declare const urlBiomarkersCreate: string @@ -1139,6 +1141,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { + console.debug('handleAddMoleculeToSection', value) const genesSymbolsFinder = this.state.formBiomarker.moleculesSymbolsFinder genesSymbolsFinder.data = [] this.setState({ @@ -1528,7 +1531,8 @@ export class BiomarkerFromCorrelationModal extends React.Component this.closeBiomarkerModal()} > + {/* Biomarker details modal. */} + } + closeOnEscape={false} + closeOnDimmerClick={false} + closeOnDocumentClick={false} + centered={false} + onClose={this.closeBiomarkerDetailsModal} + open={this.state.openDetailsModal} + > + + + + this.handleCancelConfirmModalState()} + onConfirm={() => this.state.confirmModal.onConfirm()} + /> + + + ) } diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx index a7a29ae7..3967f448 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx @@ -15,7 +15,7 @@ interface CreateBiomarkerButtonProps extends Partial { const CreateBiomarkerButton: React.FC = ({ experimentInfo, onCreateBiomarker, - ...buttonProps // <-- todas las props extra van acá + ...buttonProps }) => { const [open, setOpen] = useState(false) const [selectedOption, setSelectedOption] = useState('selectAll') @@ -49,8 +49,7 @@ const CreateBiomarkerButton: React.FC = ({ {/* Modal con opciones */} setOpen(false)} > Create Biomarker diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx index d9cbcba7..ff9fe3d3 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx @@ -173,13 +173,7 @@ export const ResultTableControlForm = (props: ResultTableControlFormProps) => { { - console.log('filtros del biomarker', { - selectAll, - experimentInfo, - tableControl - }) - }} + onCreateBiomarker={() => { }} /> Date: Fri, 20 Feb 2026 12:32:13 -0300 Subject: [PATCH 06/16] Normalization parameters of BiomarkerFromCorrelationModel --- .../pipeline/experiment-result/ResultTableControlForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx index ff9fe3d3..7602e80f 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx @@ -173,7 +173,6 @@ export const ResultTableControlForm = (props: ResultTableControlFormProps) => { { }} /> Date: Fri, 20 Feb 2026 12:32:43 -0300 Subject: [PATCH 07/16] Normalization BiomarkerFromCorrelationModal parameters --- .../experiment-result/BiomarkerFromCorrelationModal.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx index e11a536d..8ea6fd4a 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -45,11 +45,6 @@ type ValidationForm = { interface BiomarkerFromCorrelationModalProps { experimentInfo: ExperimentInfo; tableControl: ExperimentResultTableControl; - onCreateBiomarker: (options: { - experimentInfo: ExperimentInfo; - selectAll: boolean; - tableControl?: ExperimentResultTableControl; - }) => void; } /** BiomarkersPanel's state */ From c218f9f790597b7782fe11210390e4cdc3294aed Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Fri, 20 Feb 2026 13:11:07 -0300 Subject: [PATCH 08/16] Fix spelling mistake --- .../pipeline/experiment-result/ResultTableControlForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx index 7602e80f..651c5cf1 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/ResultTableControlForm.tsx @@ -166,7 +166,7 @@ export const ResultTableControlForm = (props: ResultTableControlFormProps) => { width={2} label={isShowingHighPrecision ? '1.234e-5' : 'p < .001'} icon={isShowingHighPrecision ? 'eye slash' : 'eye'} - title={`${isShowingHighPrecision ? 'Les s' : 'More'} precise p-value`} + title={`${isShowingHighPrecision ? 'Less' : 'More'} precise p-value`} onClick={() => props.changePrecisionState(!isShowingHighPrecision)} /> From 14acdf828500c0a2a2aed1d75ec68b1391bf0666 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Fri, 20 Feb 2026 13:20:57 -0300 Subject: [PATCH 09/16] Remove unused imports --- src/frontend/views.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/frontend/views.py b/src/frontend/views.py index 40378178..eaed2acf 100644 --- a/src/frontend/views.py +++ b/src/frontend/views.py @@ -4,26 +4,20 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework import permissions -from copy import deepcopy from typing import List, Optional, Dict from django.conf import settings from django.contrib.auth.decorators import login_required -from django.db import transaction from django.shortcuts import render from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, permissions, filters -from rest_framework.generics import get_object_or_404 from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.views import APIView -from api_service.models import Experiment from api_service.mrna_service import global_mrna_service -from biomarkers.models import Biomarker, BiomarkerState, BiomarkerOrigin, MoleculeIdentifier -from biomarkers.serializers import BiomarkerFromCorrelationAnalysisSerializer, BiomarkerSerializer, MoleculeIdentifierSerializer, \ - BiomarkerSimpleSerializer, BiomarkerSimpleUpdateSerializer +from biomarkers.models import Biomarker, BiomarkerState, BiomarkerOrigin +from biomarkers.serializers import BiomarkerSerializer, BiomarkerSimpleSerializer, BiomarkerSimpleUpdateSerializer from common.pagination import StandardResultsSetPagination from common.response import generate_json_response_or_404 -from django.db.models import QuerySet, Q +from django.db.models import Q def index_action(request): """Index view""" From c7e2de33fdc6f7629ec4ad55bff1963c6e8a97e9 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Thu, 12 Mar 2026 20:35:33 -0300 Subject: [PATCH 10/16] Fix some bugs --- .../pipeline/experiment-result/CreateBiomarkerButton.tsx | 5 ++--- src/frontend/static/frontend/src/css/gem.css | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx index 3967f448..79e21bfb 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/CreateBiomarkerButton.tsx @@ -30,11 +30,10 @@ const CreateBiomarkerButton: React.FC = ({ return ( <> - {/* Botón que abre el modal */} + {/* Button that opens the modal */} - {/* Modal con opciones */} + {/* Modal with options */} Date: Fri, 20 Mar 2026 13:02:28 -0300 Subject: [PATCH 11/16] Save 20.3 cytoscape graph --- .../static/frontend/package-lock.json | 10 +- src/frontend/static/frontend/package.json | 2 +- .../molecules/CurrentMoleculeDetails.tsx | 3 + .../molecules/MoleculesDetailsMenu.tsx | 7 + .../GeneOntologyCytoscapeChart.tsx | 2 +- .../genes/GeneAssociationsNetwork.tsx | 341 +++++++++++++ .../genes/GeneAssociationsNetworkPanel.tsx | 479 +++++++++++------- .../src/components/biomarkers/types.ts | 1 + 8 files changed, 645 insertions(+), 200 deletions(-) create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx diff --git a/src/frontend/static/frontend/package-lock.json b/src/frontend/static/frontend/package-lock.json index b6e3a75c..bbca754f 100644 --- a/src/frontend/static/frontend/package-lock.json +++ b/src/frontend/static/frontend/package-lock.json @@ -16,7 +16,7 @@ "@visx/tooltip": "^3.3.0", "apexcharts": "^3.52.0", "axios": "^1.8.4", - "cytoscape": "^3.30.2", + "cytoscape": "^3.33.1", "d3": "^7.9.0", "dayjs": "^1.11.13", "fomantic-ui-css": "^2.9.4", @@ -10122,9 +10122,10 @@ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==" }, "node_modules/cytoscape": { - "version": "3.30.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz", - "integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==", + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", "engines": { "node": ">=0.10" } @@ -23101,7 +23102,6 @@ "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.3.0.tgz", "integrity": "sha512-3PT9dW7IbIfN7JWGr4YxxFQnbN5MRaB36qIKF/eF0iC9l0/MuGSlMlgRgI7Uu8vYuGxX6AjLwsBBRYTPG7NFSA==", "license": "MIT", - "peer": true, "dependencies": { "@plotly/d3": "3.8.2", "@plotly/d3-sankey": "0.7.2", diff --git a/src/frontend/static/frontend/package.json b/src/frontend/static/frontend/package.json index fd73478c..76dd01fa 100644 --- a/src/frontend/static/frontend/package.json +++ b/src/frontend/static/frontend/package.json @@ -59,7 +59,7 @@ "@visx/tooltip": "^3.3.0", "apexcharts": "^3.52.0", "axios": "^1.8.4", - "cytoscape": "^3.30.2", + "cytoscape": "^3.33.1", "d3": "^7.9.0", "dayjs": "^1.11.13", "fomantic-ui-css": "^2.9.4", diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index 4f463eeb..1f173352 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -11,6 +11,7 @@ import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPan import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' +import { GeneExpressionRegulationNetworkPanel } from './genes/GeneAssociationsNetwork' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS @@ -52,6 +53,8 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: return + case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: + return case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx index 5a98008b..ec0b914c 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx @@ -63,6 +63,13 @@ export const MoleculesDetailsMenu = (props: MoleculesDetailsMenuProps) => { popupInfo: 'Gene Ontology (GO) is a powerful tool for understanding the biological processes, molecular functions, and cellular components associated with a gene', isVisible: isGene }, + { + name: 'Gene regulation associations', + onClick: () => props.setActiveItem(ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS), + isActive: props.activeItem === ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS, + popupInfo: 'Gene regulation associations provide insights into the regulatory relationships between genes, helping to unravel the complex mechanisms that control gene expression and cellular function', + isVisible: isGene + }, // TODO: implement // { // name: 'Actionable/Cancer genes', diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx index 9b277bba..af7df28e 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx @@ -78,7 +78,7 @@ export const GeneOntologyCytoscapeChart = (props: GeneOntologyCytoscapeChartProp style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', - 'line-color': function (edge) { + 'line-color': function (edge): any { // Sets the color of the edge depending on the relation_type attribute const relationType: OntologyRelationTermToTermFilter = edge.data('relation_type') diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx new file mode 100644 index 00000000..ad7f0e0d --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -0,0 +1,341 @@ +import React, { useEffect, useMemo, useRef } from 'react' +import cytoscape, { Core, ElementDefinition } from 'cytoscape' + +type NodeKind = 'mRNA' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' + +type Props = { + height?: number | string; + width?: number | string; +} + +const NODE_COLORS: Record = { + mRNA: '#4f46e5', + miRNA: '#db2777', + CNA: '#f59e0b', + Methylation: '#10b981', + Drug: '#64748b', +} + +const getEdgeColor = (correlation: number) => { + if (correlation <= -0.5) { return '#dc2626' } + + if (correlation >= 0.5) { return '#2563eb' } + + return '#cbd5e1' +} + +const getEdgeOpacity = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.8) { return 0.95 } + if (abs >= 0.6) { return 0.8 } + if (abs >= 0.5) { return 0.65 } + + return 0.22 +} + +const getEdgeWidth = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.9) { return 6 } + if (abs >= 0.8) { return 5 } + if (abs >= 0.7) { return 4 } + if (abs >= 0.5) { return 3 } + + return 1.5 +} + +export const GeneExpressionRegulationNetworkPanel = ({ + height = 650, + width = '100%', +}: Props) => { + const containerRef = useRef(null) + const cyRef = useRef(null) + + const elements = useMemo(() => { + const nodes: ElementDefinition[] = [ + { data: { id: 'mrna_pcna', label: 'PCNA', type: 'mRNA', size: 58 } }, + { data: { id: 'mrna_fen1', label: 'FEN1', type: 'mRNA', size: 54 } }, + { data: { id: 'mrna_rad51', label: 'RAD51', type: 'mRNA', size: 36 } }, + { data: { id: 'mrna_pold1', label: 'POLD1', type: 'mRNA', size: 32 } }, + { data: { id: 'mrna_lig1', label: 'LIG1', type: 'mRNA', size: 30 } }, + { data: { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_155', label: 'miR-155', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_200c', label: 'miR-200c', type: 'miRNA', size: 28 } }, + { data: { id: 'cna_8q24', label: 'CNA 8q24', type: 'CNA', size: 34 } }, + { data: { id: 'cna_17p_loss', label: '17p loss', type: 'CNA', size: 34 } }, + { data: { id: 'cna_1q_gain', label: '1q gain', type: 'CNA', size: 34 } }, + { data: { id: 'meth_mlh1', label: 'MLH1 meth', type: 'Methylation', size: 32 } }, + { data: { id: 'meth_mgmt', label: 'MGMT meth', type: 'Methylation', size: 32 } }, + { data: { id: 'drug_olaparib', label: 'Olaparib', type: 'Drug', size: 34 } }, + { data: { id: 'drug_cisplatin', label: 'Cisplatin', type: 'Drug', size: 34 } }, + { data: { id: 'drug_temozolomide', label: 'Temozolomide', type: 'Drug', size: 34 } }, + { data: { id: 'mrna_apex2', label: 'APEX2', type: 'mRNA', size: 32 } }, + ] + + const rawEdges = [ + ['mrna_pcna', 'mrna_fen1', 0.92], + ['mrna_pcna', 'mrna_rad51', 0.87], + ['mrna_pcna', 'mrna_pold1', 0.83], + ['mrna_pcna', 'mrna_lig1', 0.79], + ['mrna_pcna', 'mrna_apex2', 0.72], + ['mrna_fen1', 'mrna_rad51', 0.76], + ['mrna_fen1', 'mrna_pold1', 0.81], + ['mrna_fen1', 'mrna_apex2', 0.69], + ['mrna_rad51', 'mrna_apex2', 0.64], + ['mrna_pold1', 'mrna_lig1', 0.71], + ['mrna_lig1', 'mrna_apex2', 0.58], + + ['mir_21', 'mrna_pcna', -0.74], + ['mir_21', 'mrna_fen1', -0.68], + ['mir_21', 'mrna_rad51', -0.61], + ['mir_34a', 'mrna_pcna', -0.71], + ['mir_34a', 'mrna_pold1', -0.66], + ['mir_155', 'mrna_rad51', -0.78], + ['mir_155', 'mrna_apex2', -0.57], + ['mir_200c', 'mrna_lig1', -0.63], + ['mir_200c', 'mrna_fen1', -0.55], + + ['cna_8q24', 'mrna_pcna', 0.67], + ['cna_8q24', 'mrna_fen1', 0.54], + ['cna_17p_loss', 'mrna_rad51', -0.58], + ['cna_17p_loss', 'mrna_apex2', -0.52], + ['cna_1q_gain', 'mrna_pold1', 0.62], + ['cna_1q_gain', 'mrna_lig1', 0.57], + ['meth_mlh1', 'mrna_pcna', -0.53], + ['meth_mlh1', 'mrna_apex2', -0.64], + ['meth_mgmt', 'mrna_rad51', -0.59], + ['meth_mgmt', 'mrna_fen1', -0.51], + + ['drug_olaparib', 'mrna_rad51', -0.69], + ['drug_olaparib', 'mrna_fen1', -0.56], + ['drug_cisplatin', 'mrna_pcna', -0.52], + ['drug_cisplatin', 'mrna_lig1', -0.55], + ['drug_temozolomide', 'meth_mgmt', 0.73], + ['drug_temozolomide', 'mrna_apex2', -0.51], + + ['mir_21', 'cna_8q24', 0.22], + ['mir_34a', 'meth_mlh1', -0.18], + ['drug_olaparib', 'drug_cisplatin', 0.31], + ['mrna_pcna', 'drug_temozolomide', -0.27], + ['cna_1q_gain', 'mrna_apex2', 0.33], + ] as const + + const edges: ElementDefinition[] = rawEdges.map(([source, target, correlation], index) => ({ + data: { + id: `e_${index + 1}`, + source, + target, + correlation, + edgeColor: getEdgeColor(correlation), + edgeOpacity: getEdgeOpacity(correlation), + edgeWidth: getEdgeWidth(correlation), + }, + classes: Math.abs(correlation) < 0.5 ? 'weak-edge' : '', + })) + + return [...nodes, ...edges] + }, []) + + useEffect(() => { + if (!containerRef.current) { return } + + const cy = cytoscape({ + container: containerRef.current, + elements, + wheelSensitivity: 0.18, + minZoom: 0.4, + maxZoom: 2, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + width: 'data(size)', + height: 'data(size)', + shape: 'ellipse', + 'background-color': '#64748b', + color: '#ffffff', + 'font-size': 12, + 'font-weight': 600, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 90 as any, + 'text-outline-color': '#475569', + 'text-outline-width': 3, + 'border-width': 2, + 'border-color': '#ffffff', + }, + }, + { + selector: 'node[type = "mRNA"]', + style: { + 'background-color': NODE_COLORS.mRNA, + 'text-outline-color': NODE_COLORS.mRNA, + }, + }, + { + selector: 'node[type = "miRNA"]', + style: { + 'background-color': NODE_COLORS.miRNA, + 'text-outline-color': NODE_COLORS.miRNA, + }, + }, + { + selector: 'node[type = "CNA"]', + style: { + 'background-color': NODE_COLORS.CNA, + 'text-outline-color': NODE_COLORS.CNA, + }, + }, + { + selector: 'node[type = "Methylation"]', + style: { + 'background-color': NODE_COLORS.Methylation, + 'text-outline-color': NODE_COLORS.Methylation, + }, + }, + { + selector: 'node[type = "Drug"]', + style: { + 'background-color': NODE_COLORS.Drug, + 'text-outline-color': NODE_COLORS.Drug, + }, + }, + { + selector: 'edge', + style: { + width: 'data(edgeWidth)', + 'line-color': 'data(edgeColor)', + opacity: 'data(edgeOpacity)' as any, + 'curve-style': 'bezier', + }, + }, + { + selector: 'edge.weak-edge', + style: { + 'line-style': 'dashed', + }, + }, + { + selector: ':selected', + style: { + 'border-color': '#f8fafc', + 'border-width': 5, + 'line-color': '#0f172a', + 'target-arrow-color': '#0f172a', + 'source-arrow-color': '#0f172a', + }, + }, + { + selector: '.faded', + style: { + opacity: 0.12, + }, + }, + ], + layout: { + name: 'cose', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + cyRef.current = cy + + cy.on('tap', 'node', (evt) => { + const node = evt.target + const neighborhood = node.closedNeighborhood() + + cy.elements().addClass('faded') + neighborhood.removeClass('faded') + }) + + cy.on('tap', (evt) => { + if (evt.target === cy) { + cy.elements().removeClass('faded') + cy.elements().unselect() + } + }) + + return () => { + cy.destroy() + cyRef.current = null + } + }, [elements]) + + return ( +
+
+
+ + + + + + + +
+
+ +
+
+ ) +} + +const LegendDot = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) + +const LegendLine = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx index a7d5db85..4a4e00ab 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx @@ -1,6 +1,11 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import ky from 'ky' -import cytoscape from 'cytoscape' +import cytoscape, { + Core, + ElementDefinition, + EventObjectNode, + NodeSingular +} from 'cytoscape' import { BiomarkerMolecule } from '../../../types' import { Form, Grid, Input } from 'semantic-ui-react' import { alertGeneralError } from '../../../../../utils/util_functions' @@ -11,240 +16,314 @@ import '../../../../../css/cytoscape.css' // Defined in biomarkers.html declare const urlGeneAssociationsNetwork: string -/** Colors for the String standard: https://string-db.org/cgi/help ("Network" section). */ const COLORS_BY_STRING_RELATION = { fusion: ['Fusion', '#d90429'], coOccurrence: ['Co-occurrence', '#3a86ff'], experimental: ['Experimental', '#7209b7'], textMining: ['Text mining', '#d5ae5d'], database: ['Database', '#4cc9f0'], - coExpression: ['Co-expression', '#10002b'] // In the FAQ says 'white', but we have a white background, so it's not visible -} + coExpression: ['Co-expression', '#10002b'] +} as const -/** - * Renders the legends for the Cytoscape instance. - * @returns Component. - */ const CytoscapeLegends = () => (
Relations
    {Object.entries(COLORS_BY_STRING_RELATION).map(([_, [relationDescription, color]]) => ( -
  • {relationDescription}
  • +
  • + + {relationDescription} +
  • ))}
) -/** GeneAssociationsNetworkPanel props. */ interface GeneAssociationsNetworkPanelProps { - /** Selected BiomarkerMolecule instance to show the options. */ - selectedGene: BiomarkerMolecule, + selectedGene: BiomarkerMolecule } -export const GeneAssociationsNetworkPanel = (props: GeneAssociationsNetworkPanelProps) => { - const abortControllerTerm = useRef(new AbortController()) - const [minCombinedScore, setMinCombinedScore] = React.useState(950) +type CytoscapeResponseData = + | ElementDefinition[] + | { + nodes?: ElementDefinition[] + edges?: ElementDefinition[] + } + +export const GeneAssociationsNetworkPanel = ({ selectedGene }: GeneAssociationsNetworkPanelProps) => { + const cyContainerRef = useRef(null) + const cyRef = useRef(null) + const abortControllerRef = useRef(null) + const positionsRef = useRef>({}) + const [minCombinedScore, setMinCombinedScore] = useState(950) + + const cytoscapeStyles = [ + { + selector: 'core', + style: { + 'selection-box-color': '#AAD8FF', + 'selection-box-border-color': '#8BB0D0', + 'selection-box-opacity': 0.5 + } as any + }, + { + selector: 'node', + style: { + width: 'mapData(score, 0, 0.006769776522008331, 20, 60)', + height: 'mapData(score, 0, 0.006769776522008331, 20, 60)', + content: 'data(name)', + 'font-size': '12px', + 'text-valign': 'center', + 'text-halign': 'center', + 'background-color': '#555', + 'text-outline-color': '#555', + 'text-outline-width': '2px', + color: '#fff', + 'overlay-padding': '6px', + 'z-index': 10 + } + }, + { + selector: 'node[?attr]', + style: { + shape: 'rectangle', + 'background-color': '#aaa', + 'text-outline-color': '#aaa', + width: '16px', + height: '16px', + 'font-size': '6px', + 'z-index': 1 + } + }, + { + selector: 'node[?query]', + style: { + 'background-clip': 'none', + 'background-fit': 'contain' + } + }, + { + selector: 'node:selected', + style: { + 'border-width': '6px', + 'border-color': '#AAD8FF', + 'border-opacity': 0.5, + 'background-color': '#77828C', + 'text-outline-color': '#77828C' + } + }, + { + selector: 'edge', + style: { + 'curve-style': 'haystack', + 'haystack-radius': 0.5, + opacity: 0.4, + 'line-color': '#bbb', + width: 'mapData(weight, 0, 1, 1, 8)', + 'overlay-padding': '3px' + } + }, + { + selector: 'node.unhighlighted', + style: { + opacity: 0.2 + } + }, + { + selector: 'edge.unhighlighted', + style: { + opacity: 0.05 + } + }, + { + selector: '.highlighted', + style: { + 'z-index': 999999 + } + }, + { + selector: 'node.highlighted', + style: { + 'border-width': '6px', + 'border-color': '#AAD8FF', + 'border-opacity': 0.5, + 'background-color': '#394855', + 'text-outline-color': '#394855' + } + }, + { + selector: 'edge.filtered', + style: { + opacity: 0 + } + }, + { + selector: 'edge[group="fusion"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.fusion[1] + } + }, + { + selector: 'edge[group="coOccurrence"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.coOccurrence[1] + } + }, + { + selector: 'edge[group="experimental"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.experimental[1] + } + }, + { + selector: 'edge[group="textMining"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.textMining[1] + } + }, + { + selector: 'edge[group="database"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.database[1] + } + }, + { + selector: 'edge[group="coExpression"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.coExpression[1] + } + } + ] + + const normalizeElements = (elements: CytoscapeResponseData): ElementDefinition[] => { + if (Array.isArray(elements)) { + return elements + } + + return [...(elements.nodes || []), ...(elements.edges || [])] + } + + const createCy = () => { + if (cyRef.current || !cyContainerRef.current) { return } - /** - * Initializes the Cytoscape instance with the given elements. - * @param elements Cytoscape elements to initialize the instance. - */ - const initCytoscape = (elements: any /* TODO: type */) => { const cy = cytoscape({ - container: document.getElementById('cy'), + container: cyContainerRef.current, minZoom: 0.5, maxZoom: 1.5, - randomize: true, - animate: true, - nodeSpacing: 15, - edgeLengthVal: 45, - style: [ - { - selector: 'core', - style: { - 'selection-box-color': '#AAD8FF', - 'selection-box-border-color': '#8BB0D0', - 'selection-box-opacity': '0.5' - } - }, - { - selector: 'node', - style: { - width: 'mapData(score, 0, 0.006769776522008331, 20, 60)', - height: 'mapData(score, 0, 0.006769776522008331, 20, 60)', - content: 'data(name)', - 'font-size': '12px', - 'text-valign': 'center', - 'text-halign': 'center', - 'background-color': '#555', - 'text-outline-color': '#555', - 'text-outline-width': '2px', - color: '#fff', - 'overlay-padding': '6px', - 'z-index': '10' - } - }, - { - selector: 'node[?attr]', - style: { - shape: 'rectangle', - 'background-color': '#aaa', - 'text-outline-color': '#aaa', - width: '16px', - height: '16px', - 'font-size': '6px', - 'z-index': '1' - } - }, - { - selector: 'node[?query]', - style: { - 'background-clip': 'none', - 'background-fit': 'contain' - } - }, - { - selector: 'node:selected', - style: { - 'border-width': '6px', - 'border-color': '#AAD8FF', - 'border-opacity': '0.5', - 'background-color': '#77828C', - 'text-outline-color': '#77828C' - } - }, - { - selector: 'edge', - style: { - 'curve-style': 'haystack', - 'haystack-radius': '0.5', - opacity: '0.4', - 'line-color': '#bbb', - width: 'mapData(weight, 0, 1, 1, 8)', - 'overlay-padding': '3px' - } - }, - { - selector: 'node.unhighlighted', - style: { - opacity: '0.2' - } - }, - { - selector: 'edge.unhighlighted', - style: { - opacity: '0.05' - } - }, - { - selector: '.highlighted', - style: { - 'z-index': '999999' - } - }, - { - selector: 'node.highlighted', - style: { - 'border-width': '6px', - 'border-color': '#AAD8FF', - 'border-opacity': '0.5', - 'background-color': '#394855', - 'text-outline-color': '#394855' - } - }, - { - selector: 'edge.filtered', - style: { - opacity: '0' - } - }, - // Types of relations - { - selector: 'edge[group="fusion"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.fusion[1] - } - }, - { - selector: 'edge[group="coOccurrence"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.coOccurrence[1] - } - }, - { - selector: 'edge[group="experimental"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.experimental[1] - } - }, - { - selector: 'edge[group="textMining"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.textMining[1] - } - }, - { - selector: 'edge[group="database"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.database[1] - } - }, - { - selector: 'edge[group="coExpression"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.coExpression[1] - } + elements: [], + style: cytoscapeStyles, + layout: { + name: 'preset' + } + }) + + cy.on('dragfreeon', 'node', (evt: EventObjectNode) => { + const node = evt.target + positionsRef.current[node.id()] = node.position() + }) + + cyRef.current = cy + } + + const applyElements = (rawElements: CytoscapeResponseData) => { + const cy = cyRef.current + + if (!cy) { return } + + const incomingElements = normalizeElements(rawElements) + + cy.nodes().forEach((node: NodeSingular) => { + positionsRef.current[node.id()] = node.position() + }) + + const mergedElements = incomingElements.map((element) => { + const elementId = element?.data?.id as string | undefined + const savedPosition = elementId ? positionsRef.current[elementId] : undefined + const isNode = !('source' in (element.data || {})) && !('target' in (element.data || {})) + + if (isNode && savedPosition) { + return { + ...element, + position: savedPosition } - ], - elements + } + + return element }) - const layout = cy.elements().layout({ - name: 'random' + cy.batch(() => { + cy.elements().remove() + cy.add(mergedElements) }) - layout.run() + const hasSavedPositions = cy + .nodes() + .toArray() + .some((node: NodeSingular) => Boolean(positionsRef.current[node.id()])) + + if (hasSavedPositions) { + cy.layout({ + name: 'preset', + fit: true, + padding: 30, + animate: false + }).run() + } else { + cy.layout({ + name: 'cose', + fit: true, + padding: 30, + animate: false, + randomize: true + }).run() + } + + cy.resize() + cy.fit(undefined, 30) } - /** - * Gets all the related genes to the given gene. - * @param selectedGene Gene object. - */ - const getRelatedGenes = (selectedGene: BiomarkerMolecule) => { - const searchParams = { gene_id: selectedGene.identifier, min_combined_score: minCombinedScore } - - ky.get(urlGeneAssociationsNetwork, { searchParams: searchParams as any, signal: abortControllerTerm.current.signal }).then((response) => { - response.json().then((data: { data: any /* TODO: type */ }) => { - // The response is a CytoscapeElements object already - initCytoscape(data.data) - }).catch((err) => { - alertGeneralError() - console.log('Error parsing JSON ->', err) + const getRelatedGenes = async (gene: BiomarkerMolecule, score: number) => { + abortControllerRef.current?.abort() + abortControllerRef.current = new AbortController() + + try { + const response = await ky.get(urlGeneAssociationsNetwork, { + searchParams: { + gene_id: gene.identifier, + min_combined_score: score + } as any, + signal: abortControllerRef.current.signal }) - }).catch((err) => { - if (!abortControllerTerm.current.signal.aborted) { + + const data = await response.json<{ data: CytoscapeResponseData }>() + applyElements(data.data) + } catch (err) { + if (!abortControllerRef.current?.signal.aborted) { alertGeneralError() } console.log('Error getting experiment', err) - }) + } } useEffect(() => { - getRelatedGenes(props.selectedGene) - }, [props.selectedGene]) + createCy() - /** On unmount, cancels the request */ - useEffect(() => { return () => { - // Cleanup: cancel the ongoing request when component unmounts - abortControllerTerm.current.abort() + abortControllerRef.current?.abort() + cyRef.current?.destroy() + cyRef.current = null } }, []) + useEffect(() => { + if (!cyRef.current) { return } + + getRelatedGenes(selectedGene, minCombinedScore) + }, [selectedGene, minCombinedScore]) + return ( @@ -257,27 +336,41 @@ export const GeneAssociationsNetworkPanel = (props: GeneAssociationsNetworkPanel value={minCombinedScore} min={900} max={1000} - // TODO: implement with debounce onChange={(_e, { value }) => setMinCombinedScore(Number(value))} /> + - The combined score is computed by combining the probabilities from the different evidence channels and corrected for the probability of randomly observing an interaction. For a more detailed description please see von Mering, et al. Nucleic Acids Res. 2005 + The combined score is computed by combining the probabilities from the different evidence channels and corrected for the probability of randomly observing an interaction. For a more detailed description please see{' '} + + von Mering, et al. Nucleic Acids Res. 2005 +
)} onTop={false} onEvent='click' /> + -
+
diff --git a/src/frontend/static/frontend/src/components/biomarkers/types.ts b/src/frontend/static/frontend/src/components/biomarkers/types.ts index 131ae12d..98d2b66f 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/types.ts +++ b/src/frontend/static/frontend/src/components/biomarkers/types.ts @@ -389,6 +389,7 @@ enum ActiveBiomarkerMoleculeItemMenu { PATHWAYS, GENE_ASSOCIATIONS_NETWORK, GENE_ONTOLOGY, + GENE_REGULATION_ASSOCIATIONS, INFERENCE, DISEASES, DRUGS, From fc69571fe79f91b22b60d23cbdef47b5d3157f6a Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Wed, 8 Apr 2026 13:43:22 -0300 Subject: [PATCH 12/16] TODO Fix --- .../differential-expression/VolcanoPlot.tsx | 118 +++++++++--------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx b/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx index f1a666c9..5be970cd 100644 --- a/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx +++ b/src/frontend/static/frontend/src/components/differential-expression/VolcanoPlot.tsx @@ -74,64 +74,64 @@ export const VolcanoPlot = ({ }, ] : [] + return null + // return ( + // d.log2FC), + // y: significant.map((d) => transformP(d.pValue)), + // text: significant.map((d) => d.label ?? d.id), + // mode: 'markers', + // type: 'scattergl', + // name: 'Significant', + // marker: { size: 6 }, + // hovertemplate: + // 'Significant
' + + // 'log2FC: %{x:.2f}
' + + // '-log10(p): %{y:.2f}
' + + // '%{text}' + + // '', + // }, + // { + // x: nonsignificant.map((d) => d.log2FC), + // y: nonsignificant.map((d) => transformP(d.pValue)), + // text: nonsignificant.map((d) => d.label ?? d.id), + // mode: 'markers', + // type: 'scattergl', + // name: 'Not significant', + // marker: { size: 4, opacity: 0.6 }, + // hovertemplate: + // 'Not significant
' + + // 'log2FC: %{x:.2f}
' + + // '-log10(p): %{y:.2f}
' + + // '%{text}' + + // '', + // }, + // ]} + // layout={{ + // title: 'Volcano Plot', + // hovermode: 'closest', + // showlegend: true, + // margin: { l: 90, r: 40, t: 50, b: 70 }, - return ( - d.log2FC), - y: significant.map((d) => transformP(d.pValue)), - text: significant.map((d) => d.label ?? d.id), - mode: 'markers', - type: 'scattergl', - name: 'Significant', - marker: { size: 6 }, - hovertemplate: - 'Significant
' + - 'log2FC: %{x:.2f}
' + - '-log10(p): %{y:.2f}
' + - '%{text}' + - '', - }, - { - x: nonsignificant.map((d) => d.log2FC), - y: nonsignificant.map((d) => transformP(d.pValue)), - text: nonsignificant.map((d) => d.label ?? d.id), - mode: 'markers', - type: 'scattergl', - name: 'Not significant', - marker: { size: 4, opacity: 0.6 }, - hovertemplate: - 'Not significant
' + - 'log2FC: %{x:.2f}
' + - '-log10(p): %{y:.2f}
' + - '%{text}' + - '', - }, - ]} - layout={{ - title: 'Volcano Plot', - hovermode: 'closest', - showlegend: true, - margin: { l: 90, r: 40, t: 50, b: 70 }, - - // Axis labels (normal, not floating) - xaxis: { - title: { text: 'log2(Fold Change)', standoff: 20 }, - zeroline: false, - }, - yaxis: { - title: { text: '-log10(p-value)', standoff: 10 }, - zeroline: false, - }, - shapes: thresholdShapes, - }} - config={{ - responsive: true, - displayModeBar: true, - }} - style={{ width: '100%', height: '500px' }} - /> - ) + // // Axis labels (normal, not floating) + // xaxis: { + // title: { text: 'log2(Fold Change)', standoff: 20 }, + // zeroline: false, + // }, + // yaxis: { + // title: { text: '-log10(p-value)', standoff: 10 }, + // zeroline: false, + // }, + // shapes: thresholdShapes, + // }} + // config={{ + // responsive: true, + // displayModeBar: true, + // }} + // style={{ width: '100%', height: '500px' }} + // /> + // ) } From 8896267de034dadadf3a934977e874dff5209896 Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 26 Feb 2026 20:39:08 -0300 Subject: [PATCH 13/16] Add tissues app and tissue FK integration Introduce a new read-only tissues app (model, admin, serializer, views, URLs, app config) with initial migration and a data migration loading a list of tissue names. Add tissue ForeignKey fields to CGDSStudy and UserFile with corresponding migrations. Update serializers to accept tissue_id (writable PK) and include nested tissue representation on retrieve; persist tissue on updates/creates. Expose tissues in the REST API and enable filtering by tissue for CGDSStudy and UserFile (DjangoFilterBackend imports and filterset_fields). Register the app in settings and project URLs, and add a frontend URL constant for tissues. Admin/UI prevents adding/changing/deleting tissues (read-only). --- src/datasets_synchronization/models.py | 2 +- src/datasets_synchronization/serializers.py | 16 ++++++ src/datasets_synchronization/views.py | 3 +- .../migrations/0002_load_initial_tissues.py | 50 +++++++++++++++++++ src/tissues/models.py | 1 - src/tissues/serializers.py | 2 +- src/user_files/admin.py | 5 +- src/user_files/models.py | 3 +- src/user_files/serializers.py | 7 ++- src/user_files/views.py | 2 +- 10 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 src/tissues/migrations/0002_load_initial_tissues.py diff --git a/src/datasets_synchronization/models.py b/src/datasets_synchronization/models.py index 43a7b472..45166fd6 100644 --- a/src/datasets_synchronization/models.py +++ b/src/datasets_synchronization/models.py @@ -289,7 +289,7 @@ class CGDSStudy(models.Model): null=True, related_name='cgds_studies_as_clinical_sample_dataset' ) - tissues = models.ManyToManyField(Tissue, blank=True) + tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) task_id: Optional[str] = models.CharField(max_length=100, blank=True, null=True) # Celery Task ID def __str__(self) -> str: diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index 3f78d88f..73a0cac8 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -8,6 +8,8 @@ from common.response import ResponseStatus from .enums import CreateCGDSStudyResponseCode from .models import CGDSStudy, CGDSDataset, SurvivalColumnsTupleCGDSDataset +from tissues.models import Tissue +from tissues.serializers import TissueSerializer from django.db.models import Q @@ -54,6 +56,13 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') + # tissue_id: writable PK field; tissue: read-only nested (set in to_representation) + tissue_id = serializers.PrimaryKeyRelatedField( + queryset=Tissue.objects.all(), + source='tissue', + allow_null=True, + required=False + ) class Meta: model = CGDSStudy @@ -63,6 +72,12 @@ class Meta: def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() + def to_representation(self, instance: CGDSStudy): + """Makes a nested representation of the tissue field on GET requests.""" + data = super().to_representation(instance) + data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + return data + def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: """ Creates a CGDSDataset instance from a request data. @@ -296,6 +311,7 @@ def update(self, instance: CGDSStudy, validated_data): instance.description = validated_data.get('description', instance.description) instance.url = validated_data.get('url', instance.url) instance.url_study_info = validated_data.get('url_study_info', instance.url_study_info) + instance.tissue = validated_data.get('tissue', instance.tissue) # Updates datasets instance.mrna_dataset = mrna_dataset diff --git a/src/datasets_synchronization/views.py b/src/datasets_synchronization/views.py index 3e7097f7..ec5997be 100644 --- a/src/datasets_synchronization/views.py +++ b/src/datasets_synchronization/views.py @@ -14,6 +14,7 @@ from .models import CGDSStudy, CGDSDatasetSynchronizationState, CGDSStudySynchronizationState, CGDSDataset from rest_framework import generics, permissions, filters from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework import DjangoFilterBackend from user_files.models_choices import FileType from .serializers import CGDSStudySerializer from django.shortcuts import render, get_object_or_404 @@ -84,7 +85,7 @@ def get_queryset(self): permission_classes = [permissions.IsAuthenticated] pagination_class = StandardResultsSetPagination filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tissues'] + filterset_fields = ['tissue'] search_fields = ['name', 'description'] ordering_fields = '__all__' diff --git a/src/tissues/migrations/0002_load_initial_tissues.py b/src/tissues/migrations/0002_load_initial_tissues.py new file mode 100644 index 00000000..f181bf15 --- /dev/null +++ b/src/tissues/migrations/0002_load_initial_tissues.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.19 on 2026-02-26 22:58 + +from django.db import migrations + +TISSUES = [ + 'Cerebro (GBM)', + 'Cerebro (Glioma)', + 'Colon', + 'Cérvix', + 'Estómago', + 'Esófago', + 'Glándula Adrenal', + 'Hígado', + 'Mama', + 'Ovario', + 'Piel', + 'Próstata', + 'Pulmón', + 'Pulmón (Escamoso)', + 'Páncreas', + 'Recto', + 'Riñón (KIRC)', + 'Riñón (KIRP)', + 'Sangre (Leucemia)', + 'Testículo', + 'Tiroides', + 'Vejiga', + 'Útero', +] + + +def load_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.bulk_create([Tissue(name=name) for name in TISSUES]) + + +def unload_tissues(apps, schema_editor): + Tissue = apps.get_model('tissues', 'Tissue') + Tissue.objects.filter(name__in=TISSUES).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('tissues', '0001_initial'), + ] + + operations = [ + migrations.RunPython(load_tissues, reverse_code=unload_tissues), + ] \ No newline at end of file diff --git a/src/tissues/models.py b/src/tissues/models.py index d8fe7f7a..9b769759 100644 --- a/src/tissues/models.py +++ b/src/tissues/models.py @@ -5,7 +5,6 @@ class Tissue(models.Model): """Reference model for tissue types. Tissues are read-only and cannot be deleted.""" name = models.CharField(max_length=100, unique=True) - code = models.CharField(max_length=100, unique=True) # noqa: populated by migration 0003 class Meta: ordering = ['name'] diff --git a/src/tissues/serializers.py b/src/tissues/serializers.py index 9329da93..6a106382 100644 --- a/src/tissues/serializers.py +++ b/src/tissues/serializers.py @@ -5,4 +5,4 @@ class TissueSerializer(serializers.ModelSerializer): class Meta: model = Tissue - fields = ['id', 'name', 'code'] + fields = ['id', 'name'] diff --git a/src/user_files/admin.py b/src/user_files/admin.py index 2e05957d..6a05a0b6 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -4,9 +4,8 @@ class UserFileAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'file_type', 'upload_date', 'contains_nan_values', 'number_of_rows', - 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', - 'tissue_list') - list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissues') + 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', 'tissue') + list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissue') search_fields = ('name', 'description', 'user__username') filter_horizontal = ('tissues',) diff --git a/src/user_files/models.py b/src/user_files/models.py index 35f9ee75..46f1d5d7 100644 --- a/src/user_files/models.py +++ b/src/user_files/models.py @@ -15,6 +15,7 @@ from institutions.models import Institution from tags.models import Tag from tissues.models import Tissue +from tissues.models import Tissue from user_files.models_choices import FileType, FileDecimalSeparator from user_files.utils import get_decimal_separator_and_numerical_data, read_excel_in_chunks @@ -33,7 +34,7 @@ class UserFile(models.Model): file_obj = models.FileField(upload_to=user_directory_path) file_type = models.IntegerField(choices=FileType.choices) tag = models.ForeignKey(Tag, on_delete=models.SET_NULL, blank=True, null=True) - tissues = models.ManyToManyField(Tissue, blank=True) + tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) upload_date = models.DateTimeField(auto_now_add=True, blank=False, null=True) user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) institutions = models.ManyToManyField(Institution, blank=True) diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index 6e175ba4..50433fe0 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,6 +10,7 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer +from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -39,7 +40,7 @@ class UserFileSerializer(serializers.ModelSerializer): class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissue', 'tissue_id', 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] @@ -65,6 +66,8 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data + data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( instance.survival_columns, @@ -124,7 +127,7 @@ def update(self, instance: UserFile, validated_data): instance.file_type = validated_data.get('file_type', instance.file_type) instance.description = validated_data.get('description', instance.description) instance.tag = validated_data.get('tag') - instance.tissues.set(validated_data.get('tissues', [])) + instance.tissue = validated_data.get('tissue') instance.institutions.set(validated_data.get('institutions', [])) instance.is_cpg_site_id = validated_data.get('is_cpg_site_id') instance.platform = validated_data.get('platform') diff --git a/src/user_files/views.py b/src/user_files/views.py index f2af2d22..82098375 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -149,7 +149,7 @@ def get_queryset(self): serializer_class = UserFileWithoutFileObjSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tag', 'file_type', 'institutions', 'tissues'] + filterset_fields = ['tag', 'file_type', 'institutions', 'tissue'] search_fields = ['name', 'description'] ordering_fields = ['name', 'description', 'upload_date', 'tag', 'user', 'file_type'] pagination_class = StandardResultsSetPagination From 8742d95328524febd35391eb6605130ac7dbc337 Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 5 Mar 2026 19:51:46 -0300 Subject: [PATCH 14/16] Make tissue a ManyToMany on files and studies Convert Tissue FK to ManyToMany on CGDSStudy and UserFile and update related code. Models updated to use tissues M2M; serializers changed (tissue_id -> tissue_ids, nested tissues in responses, create/update now set M2M relations); views' filterset_fields switched to 'tissues'; admin updated to filter and edit M2M with filter_horizontal and a user-facing tissue list column. Added migrations to replace FK with M2M for datasets_synchronization and user_files, and a new tissues migration that adds a unique code field and loads standardized initial tissue data (replacing the previous locale-specific seed). Run migrations after pulling these changes. --- src/datasets_synchronization/models.py | 2 +- src/datasets_synchronization/serializers.py | 13 +++-- src/datasets_synchronization/views.py | 2 +- .../migrations/0002_load_initial_tissues.py | 50 ------------------- src/tissues/models.py | 1 + src/tissues/serializers.py | 2 +- src/user_files/admin.py | 7 +-- src/user_files/models.py | 2 +- src/user_files/serializers.py | 13 +++-- src/user_files/views.py | 2 +- 10 files changed, 26 insertions(+), 68 deletions(-) delete mode 100644 src/tissues/migrations/0002_load_initial_tissues.py diff --git a/src/datasets_synchronization/models.py b/src/datasets_synchronization/models.py index 45166fd6..43a7b472 100644 --- a/src/datasets_synchronization/models.py +++ b/src/datasets_synchronization/models.py @@ -289,7 +289,7 @@ class CGDSStudy(models.Model): null=True, related_name='cgds_studies_as_clinical_sample_dataset' ) - tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) + tissues = models.ManyToManyField(Tissue, blank=True) task_id: Optional[str] = models.CharField(max_length=100, blank=True, null=True) # Celery Task ID def __str__(self) -> str: diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index 73a0cac8..bdc73ccc 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -56,11 +56,11 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') - # tissue_id: writable PK field; tissue: read-only nested (set in to_representation) - tissue_id = serializers.PrimaryKeyRelatedField( + # tissue_ids: writable list of PKs; tissues: read-only nested list (set in to_representation) + tissue_ids = serializers.PrimaryKeyRelatedField( queryset=Tissue.objects.all(), - source='tissue', - allow_null=True, + source='tissues', + many=True, required=False ) @@ -73,9 +73,9 @@ def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() def to_representation(self, instance: CGDSStudy): - """Makes a nested representation of the tissue field on GET requests.""" + """Makes a nested representation of the tissues field on GET requests.""" data = super().to_representation(instance) - data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data return data def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: @@ -311,7 +311,6 @@ def update(self, instance: CGDSStudy, validated_data): instance.description = validated_data.get('description', instance.description) instance.url = validated_data.get('url', instance.url) instance.url_study_info = validated_data.get('url_study_info', instance.url_study_info) - instance.tissue = validated_data.get('tissue', instance.tissue) # Updates datasets instance.mrna_dataset = mrna_dataset diff --git a/src/datasets_synchronization/views.py b/src/datasets_synchronization/views.py index ec5997be..ccfe9ab0 100644 --- a/src/datasets_synchronization/views.py +++ b/src/datasets_synchronization/views.py @@ -85,7 +85,7 @@ def get_queryset(self): permission_classes = [permissions.IsAuthenticated] pagination_class = StandardResultsSetPagination filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tissue'] + filterset_fields = ['tissues'] search_fields = ['name', 'description'] ordering_fields = '__all__' diff --git a/src/tissues/migrations/0002_load_initial_tissues.py b/src/tissues/migrations/0002_load_initial_tissues.py deleted file mode 100644 index f181bf15..00000000 --- a/src/tissues/migrations/0002_load_initial_tissues.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 4.2.19 on 2026-02-26 22:58 - -from django.db import migrations - -TISSUES = [ - 'Cerebro (GBM)', - 'Cerebro (Glioma)', - 'Colon', - 'Cérvix', - 'Estómago', - 'Esófago', - 'Glándula Adrenal', - 'Hígado', - 'Mama', - 'Ovario', - 'Piel', - 'Próstata', - 'Pulmón', - 'Pulmón (Escamoso)', - 'Páncreas', - 'Recto', - 'Riñón (KIRC)', - 'Riñón (KIRP)', - 'Sangre (Leucemia)', - 'Testículo', - 'Tiroides', - 'Vejiga', - 'Útero', -] - - -def load_tissues(apps, schema_editor): - Tissue = apps.get_model('tissues', 'Tissue') - Tissue.objects.bulk_create([Tissue(name=name) for name in TISSUES]) - - -def unload_tissues(apps, schema_editor): - Tissue = apps.get_model('tissues', 'Tissue') - Tissue.objects.filter(name__in=TISSUES).delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('tissues', '0001_initial'), - ] - - operations = [ - migrations.RunPython(load_tissues, reverse_code=unload_tissues), - ] \ No newline at end of file diff --git a/src/tissues/models.py b/src/tissues/models.py index 9b769759..d8fe7f7a 100644 --- a/src/tissues/models.py +++ b/src/tissues/models.py @@ -5,6 +5,7 @@ class Tissue(models.Model): """Reference model for tissue types. Tissues are read-only and cannot be deleted.""" name = models.CharField(max_length=100, unique=True) + code = models.CharField(max_length=100, unique=True) # noqa: populated by migration 0003 class Meta: ordering = ['name'] diff --git a/src/tissues/serializers.py b/src/tissues/serializers.py index 6a106382..9329da93 100644 --- a/src/tissues/serializers.py +++ b/src/tissues/serializers.py @@ -5,4 +5,4 @@ class TissueSerializer(serializers.ModelSerializer): class Meta: model = Tissue - fields = ['id', 'name'] + fields = ['id', 'name', 'code'] diff --git a/src/user_files/admin.py b/src/user_files/admin.py index 6a05a0b6..40c90524 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -4,13 +4,14 @@ class UserFileAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'file_type', 'upload_date', 'contains_nan_values', 'number_of_rows', - 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', 'tissue') - list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissue') + 'number_of_samples', 'decimal_separator', 'is_public', 'is_cpg_site_id', 'platform', + 'tissue_list') + list_filter = ('file_type', 'upload_date', 'is_public', 'is_cpg_site_id', 'tissues') search_fields = ('name', 'description', 'user__username') filter_horizontal = ('tissues',) def tissue_list(self, obj): - return ', '.join(obj.tissues.values_list('name', flat=True)) + return ', '.join(t.name for t in obj.tissues.all()) tissue_list.short_description = 'Tissues' diff --git a/src/user_files/models.py b/src/user_files/models.py index 46f1d5d7..04cf1b24 100644 --- a/src/user_files/models.py +++ b/src/user_files/models.py @@ -34,7 +34,7 @@ class UserFile(models.Model): file_obj = models.FileField(upload_to=user_directory_path) file_type = models.IntegerField(choices=FileType.choices) tag = models.ForeignKey(Tag, on_delete=models.SET_NULL, blank=True, null=True) - tissue = models.ForeignKey(Tissue, on_delete=models.SET_NULL, blank=True, null=True) + tissues = models.ManyToManyField(Tissue, blank=True) upload_date = models.DateTimeField(auto_now_add=True, blank=False, null=True) user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) institutions = models.ManyToManyField(Institution, blank=True) diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index 50433fe0..8d086f1e 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,6 +10,7 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer +from tissues.models import Tissue from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -37,10 +38,16 @@ class Meta: class UserFileSerializer(serializers.ModelSerializer): user = UserSimpleSerializer(many=False, read_only=True) survival_columns = SurvivalColumnsTupleUserFileSimpleSerializer(many=True, read_only=True) + tissue_ids = serializers.PrimaryKeyRelatedField( + queryset=Tissue.objects.all(), + source='tissues', + many=True, + required=False + ) class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissue', 'tissue_id', + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'tissue_ids', 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] @@ -66,7 +73,7 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data - data['tissue'] = TissueSerializer(instance.tissue).data if instance.tissue else None + data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( @@ -127,7 +134,7 @@ def update(self, instance: UserFile, validated_data): instance.file_type = validated_data.get('file_type', instance.file_type) instance.description = validated_data.get('description', instance.description) instance.tag = validated_data.get('tag') - instance.tissue = validated_data.get('tissue') + instance.tissues.set(validated_data.get('tissues', [])) instance.institutions.set(validated_data.get('institutions', [])) instance.is_cpg_site_id = validated_data.get('is_cpg_site_id') instance.platform = validated_data.get('platform') diff --git a/src/user_files/views.py b/src/user_files/views.py index 82098375..f2af2d22 100644 --- a/src/user_files/views.py +++ b/src/user_files/views.py @@ -149,7 +149,7 @@ def get_queryset(self): serializer_class = UserFileWithoutFileObjSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [filters.OrderingFilter, filters.SearchFilter, DjangoFilterBackend] - filterset_fields = ['tag', 'file_type', 'institutions', 'tissue'] + filterset_fields = ['tag', 'file_type', 'institutions', 'tissues'] search_fields = ['name', 'description'] ordering_fields = ['name', 'description', 'upload_date', 'tag', 'user', 'file_type'] pagination_class = StandardResultsSetPagination From f1972c35fe9e765ba72b54c7cea44350b54a8753 Mon Sep 17 00:00:00 2001 From: Hernan Date: Fri, 20 Mar 2026 11:36:07 -0300 Subject: [PATCH 15/16] Refactor tissue representation in serializers and admin; remove unused fields --- src/datasets_synchronization/serializers.py | 15 --------------- src/user_files/admin.py | 2 +- src/user_files/serializers.py | 12 +----------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/datasets_synchronization/serializers.py b/src/datasets_synchronization/serializers.py index bdc73ccc..3f78d88f 100644 --- a/src/datasets_synchronization/serializers.py +++ b/src/datasets_synchronization/serializers.py @@ -8,8 +8,6 @@ from common.response import ResponseStatus from .enums import CreateCGDSStudyResponseCode from .models import CGDSStudy, CGDSDataset, SurvivalColumnsTupleCGDSDataset -from tissues.models import Tissue -from tissues.serializers import TissueSerializer from django.db.models import Q @@ -56,13 +54,6 @@ class CGDSStudySerializer(serializers.ModelSerializer): clinical_sample_dataset = CGDSDatasetSerializer(required=False, allow_null=True) version = serializers.IntegerField(read_only=True) is_last_version = serializers.SerializerMethodField(method_name='get_is_last_version') - # tissue_ids: writable list of PKs; tissues: read-only nested list (set in to_representation) - tissue_ids = serializers.PrimaryKeyRelatedField( - queryset=Tissue.objects.all(), - source='tissues', - many=True, - required=False - ) class Meta: model = CGDSStudy @@ -72,12 +63,6 @@ class Meta: def get_is_last_version(study: CGDSStudy) -> bool: return study.version == study.get_last_version() - def to_representation(self, instance: CGDSStudy): - """Makes a nested representation of the tissues field on GET requests.""" - data = super().to_representation(instance) - data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data - return data - def __create_cgds_dataset(self, validated_data_pop: OrderedDict) -> Optional[CGDSDataset]: """ Creates a CGDSDataset instance from a request data. diff --git a/src/user_files/admin.py b/src/user_files/admin.py index 40c90524..2e05957d 100644 --- a/src/user_files/admin.py +++ b/src/user_files/admin.py @@ -11,7 +11,7 @@ class UserFileAdmin(admin.ModelAdmin): filter_horizontal = ('tissues',) def tissue_list(self, obj): - return ', '.join(t.name for t in obj.tissues.all()) + return ', '.join(obj.tissues.values_list('name', flat=True)) tissue_list.short_description = 'Tissues' diff --git a/src/user_files/serializers.py b/src/user_files/serializers.py index 8d086f1e..6e175ba4 100644 --- a/src/user_files/serializers.py +++ b/src/user_files/serializers.py @@ -10,8 +10,6 @@ from users.serializers import UserSimpleSerializer from institutions.serializers import InstitutionSimpleSerializer from tags.serializers import TagSerializer -from tissues.models import Tissue -from tissues.serializers import TissueSerializer from user_files.models import UserFile @@ -38,16 +36,10 @@ class Meta: class UserFileSerializer(serializers.ModelSerializer): user = UserSimpleSerializer(many=False, read_only=True) survival_columns = SurvivalColumnsTupleUserFileSimpleSerializer(many=True, read_only=True) - tissue_ids = serializers.PrimaryKeyRelatedField( - queryset=Tissue.objects.all(), - source='tissues', - many=True, - required=False - ) class Meta: model = UserFile - fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'tissue_ids', + fields = ['id', 'name', 'description', 'file_obj', 'file_type', 'tag', 'tag_id', 'tissues', 'upload_date', 'institutions', 'number_of_rows', 'number_of_samples', 'user', 'contains_nan_values', 'column_used_as_index', 'is_cpg_site_id', 'platform', 'survival_columns', 'is_public'] @@ -73,8 +65,6 @@ def to_representation(self, instance): if instance.tag: data['tag'] = TagSerializer(instance.tag).data - data['tissues'] = TissueSerializer(instance.tissues.all(), many=True).data - data['institutions'] = InstitutionSimpleSerializer(instance.institutions, many=True, read_only=True).data data['survival_columns'] = SurvivalColumnsTupleUserFileSimpleSerializer( instance.survival_columns, From 77d3eef0dd57ee8d4c68aa283f5b7d41a04391d4 Mon Sep 17 00:00:00 2001 From: GonzaGomez Date: Tue, 21 Apr 2026 20:08:45 -0300 Subject: [PATCH 16/16] Fixes styles in create Biomarker modal and remove unused imports --- .../BiomarkerFromCorrelationModal.tsx | 30 +++++-- src/frontend/static/frontend/src/css/gem.css | 87 +++++++++++++++++++ src/frontend/views.py | 14 --- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx index 8ea6fd4a..a6123bb0 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/BiomarkerFromCorrelationModal.tsx @@ -83,7 +83,8 @@ interface BiomarkerFromCorrelationModalState { openDetailsModal2: boolean, selectedOption: SelectedOption, openSelectOptionModal: boolean, - experimentInfoWithoutFilters: ExperimentInfo + experimentInfoWithoutFilters: ExperimentInfo, + modalReady: boolean, } /** @@ -119,6 +120,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { this.setState({ loadingFullBiomarkerId: biomarkerSimple.id }) ky.get(urlBiomarkersCRUD + '/' + biomarkerSimple.id + '/', { signal: this.abortController.signal }).then((response) => { - response.json().then((jsonResponse: Biomarker | PromiseLike) => { + response.json().then((jsonResponse) => { resolve(jsonResponse) }).catch((err) => { console.error('Error parsing JSON on Biomarker retrieval:', err) @@ -672,7 +674,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { - response.json().then((jsonResponse: MoleculeFinderResult[]) => { + response.json().then((jsonResponse) => { const formBiomarker = this.state.formBiomarker const checkedIgnoreProposedAlias = this.state.checkedIgnoreProposedAlias // For short @@ -832,7 +834,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { - response.json().then((jsonResponse: { [key: string]: string[] }) => { + response.json<{ [key: string]: string[] }>().then((jsonResponse) => { const genes = Object.entries(jsonResponse) for (const gene of genes) { @@ -1056,7 +1058,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { - response.json().then((_jsonResponse: Biomarker) => { + response.json().then((_jsonResponse) => { this.closeModalWithSuccessMsg('Biomarker created successfully') }).catch((err) => { console.log('Error parsing JSON ->', err) @@ -1075,7 +1077,7 @@ export class BiomarkerFromCorrelationModal extends React.Component { - response.json().then((_jsonResponse: Biomarker) => { + response.json().then((_jsonResponse) => { this.closeModalWithSuccessMsg('Biomarker edited successfully') }).catch((err) => { console.log('Error parsing JSON ->', err) @@ -1216,7 +1218,12 @@ export class BiomarkerFromCorrelationModal extends React.Component this.closeBiomarkerModal()} + onOpen={() => { + this.setState({ modalReady: true }, () => { + window.dispatchEvent(new Event('resize')) + }) + }} > + {/* Biomarker details modal. */} diff --git a/src/frontend/static/frontend/src/css/gem.css b/src/frontend/static/frontend/src/css/gem.css index 27c3edfb..94bb6fb2 100644 --- a/src/frontend/static/frontend/src/css/gem.css +++ b/src/frontend/static/frontend/src/css/gem.css @@ -206,3 +206,90 @@ opacity: 1; text-decoration: underline; } + +.biomarkers--side--bar--box { + margin: 1rem 0; +} + +.biomarkers--side--bar--buttons-group { + width: 100%; +} + +.biomarkers--side--bar--input--selection { + margin: 0.5rem 0; + width: 100%; +} + +.biomarkers--modal--container { + height: 100%; +} + +.large-modal { + min-height: 92% !important; + width: 92% !important; +} + +.space-modal:nth-child(1) { + display: flex !important; + flex-direction: column; + justify-content: space-between; +} + +.biomarkers--side--bar--container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.biomarkers--side--bar--container--item--margin { + margin: 0.5rem 0; +} + +.biomarkers--side--bar--buttons--box { + align-self: flex-end; + display: flex; + justify-self: flex-end; + margin-top: 0.5rem; +} + +.biomarkers--side--bar--validation--items { + margin: 8px 0px 8px 0px; + width: 100%; +} + +.biomarkers--molecules--container { + max-width: 100%; + height: 32vh; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + margin-top: 0.5rem; +} + +.biomarkers--molecules--container--item { + margin: 0.5rem; + flex: 1; + display: flex; + justify-content: center; + align-self: center; +} + +.biomarkers--molecules--container--grid { + width: 100%; +} + +.biomarker--section--icon { + padding: 0 0.5rem; + cursor: pointer; +} + +.biomarker--section--button { + cursor: default !important; +} + +.row-container { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; +} \ No newline at end of file diff --git a/src/frontend/views.py b/src/frontend/views.py index eaed2acf..5a9a181f 100644 --- a/src/frontend/views.py +++ b/src/frontend/views.py @@ -1,23 +1,9 @@ from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.conf import settings -from rest_framework.views import APIView -from rest_framework.request import Request -from rest_framework import permissions -from typing import List, Optional, Dict from django.conf import settings from django.contrib.auth.decorators import login_required from django.shortcuts import render -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import generics, permissions, filters -from rest_framework.request import Request -from rest_framework.views import APIView -from api_service.mrna_service import global_mrna_service -from biomarkers.models import Biomarker, BiomarkerState, BiomarkerOrigin -from biomarkers.serializers import BiomarkerSerializer, BiomarkerSimpleSerializer, BiomarkerSimpleUpdateSerializer -from common.pagination import StandardResultsSetPagination -from common.response import generate_json_response_or_404 -from django.db.models import Q def index_action(request): """Index view"""