diff --git a/src/constants/BrowserPaths.ts b/src/constants/BrowserPaths.ts index 481cd3a4..631f6b05 100644 --- a/src/constants/BrowserPaths.ts +++ b/src/constants/BrowserPaths.ts @@ -1,6 +1,6 @@ export const ABOUT = "/about" export const CONTACT = "/contact" -export const SEARCH = "/results" +export const RESULT = "/result" //[/:id] export const QUERY = "/query" export const HOME = "/" export const API_ERROR = "/error" diff --git a/src/constants/const.ts b/src/constants/const.ts index 0beb191b..c84306de 100644 --- a/src/constants/const.ts +++ b/src/constants/const.ts @@ -1,13 +1,18 @@ export const API_URL = process.env.REACT_APP_API_BASE_URL; export const NO_OF_ITEMS_PER_PAGE = 25; -export const MAX_IN_PLACE_DOWNLOAD_WITHOUT_EMAIL = 25; +export const PAGE_SIZES = [25, 50, 100] +export const PAGE_SIZE = 25 export const G2P_MAPPING_URI = `${API_URL}/mappings`; -export const DOWNLOAD_URI = `${API_URL}/download/stream`; -export const EMAIL_URI = `${API_URL}/email/process`; -export const API_HEADERS = { "Content-Type": "application/json", Accept: "*" }; +export const DEFAULT_HEADERS = {"Content-Type": "application/json", Accept: "*"}; + +export const CONTENT_TEXT = {"Content-Type": "text/plain"} +export const CONTENT_MULTIPART = {"Content-Type": "multipart/form-data"} export const DOWNLOAD_STATUS=`${API_URL}/download/status`; export const LOCAL_DOWNLOADS='PV_downloads'; +export const LOCAL_RESULTS='PV_results'; export const DISMISS_BANNER = 'PV_banner'; + +// TODO resubscribe option - clears the localData export const SUBSCRIPTION_STATUS = 'PV_subscribed'; export const TITLE='EMBL-EBI ProtVar - Contextualising human missense variation' export const PV_FTP = 'https://ftp.ebi.ac.uk/pub/databases/ProtVar' \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 3a6886ab..f8cc7cb8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ +import './styles/index.scss'; import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; -import './styles/index.scss'; import reportWebVitals from './reportWebVitals'; import App from './ui/App'; diff --git a/src/provider/LocalStorageContextProps.tsx b/src/provider/LocalStorageContextProps.tsx new file mode 100644 index 00000000..241894a4 --- /dev/null +++ b/src/provider/LocalStorageContextProps.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, ReactNode } from 'react'; + +interface LocalStorageContextProps { + getValue: (key: string) => T | null; + setValue: (key: string, value: T) => void; + deleteValue: (key: string) => void; +} + +const LocalStorageContext = createContext(undefined); + +export const useLocalStorageContext = () => { + const context = useContext(LocalStorageContext); + if (!context) { + throw new Error('useLocalStorageContext must be used within a LocalStorageProvider'); + } + return context; +}; + +export const LOCAL_STORAGE_SET = "localStore" + +export const LocalStorageProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + + const getValue = (key: string): T | null => { + const storedValue = localStorage.getItem(key); + return storedValue ? JSON.parse(storedValue) : null; + }; + + const setValue = (key: string, value: T): void => { + localStorage.setItem(key, JSON.stringify(value)); + const event = new CustomEvent(LOCAL_STORAGE_SET, { detail: key }); + window.dispatchEvent(event) + }; + + const deleteValue = (key: string): void => { + localStorage.removeItem(key); + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/services/ProtVarService.ts b/src/services/ProtVarService.ts index c0d9c6f2..a1ea46ed 100644 --- a/src/services/ProtVarService.ts +++ b/src/services/ProtVarService.ts @@ -1,88 +1,158 @@ import axios, {AxiosResponse} from 'axios'; -import { setupCache } from 'axios-cache-interceptor/dist/index.bundle'; -import {API_HEADERS, API_URL, DOWNLOAD_STATUS, G2P_MAPPING_URI} from "../constants/const"; +import {setupCache} from 'axios-cache-interceptor/dist/index.bundle'; +import { + API_URL, + CONTENT_MULTIPART, + CONTENT_TEXT, + DEFAULT_HEADERS, + DOWNLOAD_STATUS, + G2P_MAPPING_URI +} from "../constants/const"; import {FunctionalResponse} from "../types/FunctionalResponse"; import {PopulationObservationResponse} from "../types/PopulationObservationResponse"; import {ProteinStructureResponse} from "../types/ProteinStructureResponse"; import MappingResponse from "../types/MappingResponse"; -import {DownloadResponse} from "../types/DownloadResponse"; +import {DownloadRecord} from "../types/DownloadRecord"; +import {IDResponse, PagedMappingResponse, ResultType} from "../types/PagedMappingResponse"; const instance = axios.create({ - baseURL: API_URL + baseURL: API_URL }); const api = setupCache(instance, {}) +// Coordinate Mapping +// POST /mappings export function mappings(inputArr: string[], assembly?: string) { - return api.post>( - G2P_MAPPING_URI, - inputArr, - { - params : { assembly }, - headers: API_HEADERS, - } - ); + return api.post>( + G2P_MAPPING_URI, + inputArr, + { + params: {assembly}, + headers: DEFAULT_HEADERS, + } + ); } +// POST /mapping/input +// IN: text +// OUT: PagedMappingResponse +// See getResult +export function submitInput(text: string, assembly?: string) { + return api.post( + `${API_URL}/mapping/input`, text, + { + params: {assembly}, // idOnly defaults to false i.e. PagedMappingResponse is returned + headers: CONTENT_TEXT + } + ); +} -export function downloadFileInput(file: File, assembly: string, email: string, jobName: string, functional: boolean, population: boolean, structure: boolean) { - const formData = new FormData(); - formData.append('file', file); - return api.post>( - `${API_URL}/download/fileInput`, - formData, - { - params : { email, jobName, function: functional, population, structure, assembly }, - headers: { - 'Content-Type': 'multipart/form-data' - }, - } - ); +// POST /mapping/input +// IN: text +// OUT: IDResponse +export function submitInputText(text: string, assembly?: string, idOnly: boolean = true) { + return api.post( + `${API_URL}/mapping/input`, text, + { + params: {assembly, idOnly}, + headers: CONTENT_TEXT + } + ); } -export function downloadTextInput(inputArr: string[], assembly: string, email: string, jobName: string, functional: boolean, population: boolean, structure: boolean) { - return api.post>( - `${API_URL}/download/textInput`, - inputArr, - { - params : { email, jobName, function: functional, population, structure, assembly }, - headers: { - 'Content-Type': 'application/json', - Accept: '*' - }, - } - ); +// POST /mapping/input +// IN: file +// OUT: IDResponse +export function submitInputFile(file: File, assembly?: string, idOnly: boolean = true) { + const formData = new FormData(); + formData.append('file', file); + return api.post>( + `${API_URL}/mapping/input`, + formData, + { + params: {assembly, idOnly}, + headers: CONTENT_MULTIPART, + } + ); +} + +// GET /mapping/input/{id} +// IN: id +// OUT: PagedMappingResponse +export function getResult(type: ResultType, id: string, page?: number, pageSize?: number, assembly: string|null = null) { + let url = '' + let params = {} + + if (type === ResultType.SEARCH) { + url = `${API_URL}/mapping/input/${id}` + params = {page, pageSize, assembly} + } else { + url = `${API_URL}/mapping/protein/${id}` + params = {page, pageSize} + } + + return api.get( + url, + { + params: params, + headers: DEFAULT_HEADERS, + } + ); } +// Annotation export function getFunctionalData(url: string) { - return api.get(url).then( - response => { - if (response.data.interactions && response.data.interactions.length > 1) { - response.data.interactions.sort((a, b) => b.pdockq - a.pdockq); - } - return response; - } - ); + return api.get(url).then( + response => { + if (response.data.interactions && response.data.interactions.length > 1) { + response.data.interactions.sort((a, b) => b.pdockq - a.pdockq); + } + return response; + } + ); } export function getPopulationData(url: string) { - return api.get(url); + return api.get(url); } export function getStructureData(url: string) { - return api.get(url); + return api.get(url); +} + +// Download +export function downloadFileInput(file: File, assembly: string, email: string, jobName: string, functional: boolean, population: boolean, structure: boolean) { + const formData = new FormData(); + formData.append('file', file); + return api.post>( + `${API_URL}/download/fileInput`, + formData, + { + params: {email, jobName, function: functional, population, structure, assembly}, + headers: CONTENT_MULTIPART, + } + ); +} + +export function downloadTextInput(inputArr: string[], assembly: string, email: string, jobName: string, functional: boolean, population: boolean, structure: boolean) { + return api.post>( + `${API_URL}/download/textInput`, + inputArr, + { + params: {email, jobName, function: functional, population, structure, assembly}, + headers: DEFAULT_HEADERS, + } + ); } export function getDownloadStatus(ids: string[]) { - return api.post( - DOWNLOAD_STATUS, - ids, - { - headers: { - 'Content-Type': 'application/json', - Accept: '*' - }, - } - ); -} \ No newline at end of file + return api.post( + DOWNLOAD_STATUS, + ids, + { + headers: DEFAULT_HEADERS, + } + ); +} diff --git a/src/styles/index.scss b/src/styles/index.scss index c43f78be..1230fc98 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -63,3 +63,34 @@ .trash-btn:hover { cursor: pointer; } + +.result-op-btn { + float: right; + font-size: 10px; + font-weight: bolder; + color: black; + padding: 4px; + border-radius: 20%; + border-bottom-color: lightgrey; +} +.result-op-btn:hover { + font-weight: bolder; + background-color: #e7e7e7; + cursor: pointer; +} + +.download-count { + float: right; + min-width: 24px; + text-align: center; + display: inline-block; + //padding: 0.1rem 0.2rem; + //margin-right: 1rem; + //margin-bottom: 0.3rem; + border: none; + border-radius: 0.75rem; + font-size: 12px; + font-weight: bold; + color: white; + background-color: #00709b; +} \ No newline at end of file diff --git a/src/types/DownloadResponse.ts b/src/types/DownloadRecord.ts similarity index 77% rename from src/types/DownloadResponse.ts rename to src/types/DownloadRecord.ts index 4d3a52a4..6a231e78 100644 --- a/src/types/DownloadResponse.ts +++ b/src/types/DownloadRecord.ts @@ -1,5 +1,5 @@ -export interface DownloadResponse { +export interface DownloadRecord { inputType: string requested: Date downloadId: string diff --git a/src/types/FormData.ts b/src/types/FormData.ts index fb895f11..9c11c855 100644 --- a/src/types/FormData.ts +++ b/src/types/FormData.ts @@ -10,4 +10,16 @@ export const initialFormData = { userInputs: [], file: null, assembly: DEFAULT_ASSEMBLY +} + +export interface Form { + text: string + file: File | null + assembly: Assembly +} + +export const initialForm = { + text: '', + file: null, + assembly: DEFAULT_ASSEMBLY } \ No newline at end of file diff --git a/src/types/MappingResponse.ts b/src/types/MappingResponse.ts index 3f433372..af1a8671 100644 --- a/src/types/MappingResponse.ts +++ b/src/types/MappingResponse.ts @@ -84,7 +84,7 @@ export interface GenomeProteinMapping { genes: Array; //input: string; } -interface Gene { +export interface Gene { ensg: string; reverseStrand: boolean; geneName: string; @@ -94,7 +94,7 @@ interface Gene { caddScore: number; } // TODO clean up unused commented properties below -interface IsoFormMapping { +export interface IsoFormMapping { accession: string; canonical: boolean; canonicalAccession: string; diff --git a/src/types/PagedMappingResponse.ts b/src/types/PagedMappingResponse.ts new file mode 100644 index 00000000..464ce911 --- /dev/null +++ b/src/types/PagedMappingResponse.ts @@ -0,0 +1,28 @@ +import { MappingResponse } from "./MappingResponse"; +import {PAGE_SIZE} from "../constants/const"; + +export enum ResultType {SEARCH, PROTEIN} +export interface PagedMappingResponse { + content: MappingResponse + id: string + page: number + pageSize: number + totalItems: number + totalPages: number + last: boolean +} + +export const toPagedMappingResponse = (mappingResponse: MappingResponse): PagedMappingResponse => { + return {content: mappingResponse, + id: "", + page: 1, + pageSize: PAGE_SIZE, + totalItems: 1, + totalPages: 1, + last: true + } +} + +export interface IDResponse { + id: string +} \ No newline at end of file diff --git a/src/types/ResultRecord.ts b/src/types/ResultRecord.ts new file mode 100644 index 00000000..5f064b0b --- /dev/null +++ b/src/types/ResultRecord.ts @@ -0,0 +1,11 @@ +// Maybe group results by Viewed & Submitted +export interface ResultRecord { + id: string // required + url: string + firstSubmitted?: string // TODO make optional - when shared, user will only have viewed this, not submitted + lastSubmitted?: string // TODO same for this + lastViewed?: string // + name?: string + numItems?: number + params?: string // page, pageSize, assembly +} \ No newline at end of file diff --git a/src/ui/App.tsx b/src/ui/App.tsx index b78b3623..c0b64c81 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,227 +1,80 @@ import React, {createContext, ReactElement, useState} from "react"; -import {useNavigate, Route, Routes} from "react-router-dom"; +import {Route, Routes} from "react-router-dom"; import HomePage from "./pages/home/HomePage"; -import SearchResultsPage from "./pages/search/SearchResultPage"; import APIErrorPage from "./pages/APIErrorPage"; -import {ERROR, INFO, WARN} from "../types/MappingResponse"; -import {convertApiMappingToTableRecords, MappingRecord,} from "../utills/Convertor"; -import {firstPage, Page} from "../utills/AppHelper"; import AboutPage from "./pages/AboutPage"; import ReleasePage from "./pages/ReleasePage"; import ContactPage from "./pages/ContactPage"; -import {ABOUT, API_ERROR, CONTACT, DOWNLOAD, HOME, QUERY, SEARCH, HELP, RELEASE} from "../constants/BrowserPaths"; -import Notify from "./elements/Notify"; +import {ABOUT, API_ERROR, CONTACT, DOWNLOAD, HELP, HOME, QUERY, RELEASE, RESULT} from "../constants/BrowserPaths"; import QueryPage from "./pages/query/QueryPage"; import {Assembly} from "../constants/CommonTypes"; -import {mappings} from "../services/ProtVarService"; import DownloadPage from "./pages/download/DownloadPage"; import HelpPage from "./pages/help/HelpPage"; -import {FormData, initialFormData} from "../types/FormData"; +import {PagedMappingResponse, ResultType} from "../types/PagedMappingResponse"; +import ResultPage from "./pages/result/ResultPage"; +import {PAGE_SIZE} from "../constants/const"; +import {LocalStorageProvider} from "../provider/LocalStorageContextProps"; const empty: ReactElement = <>; -const initialSettings = { +export interface AppState { + stdColor: boolean + showModal: boolean + modalContent: JSX.Element + // V2 + textInput: string + file: File | null + assembly: Assembly + pageSize: number + response: PagedMappingResponse | null + updateState: (key: string, value: any) => void +} + +export const initialState = { stdColor: true, showModal: false, - modalContent: empty + modalContent: empty, + // V2 + textInput: "", + file: null, + assembly: Assembly.AUTO, + pageSize: PAGE_SIZE, // needs to be localStore, not appState + response: null, + updateState: (key: string, value: any) => {} } -export const AppContext = createContext({ - ...initialSettings, - toggleStdColor: () => {}, - toggleModal: () => {}, - setModalContent: (elem: JSX.Element) => {} -}) +export const AppContext = createContext(initialState) export default function App() { - const toggleStdColor = () => { - setSettings(prevSettings => ({...prevSettings, - stdColor: prevSettings.stdColor ? false : true - })); - } - - const toggleModal = () => { - setSettings(prevSettings => ({...prevSettings, - showModal: prevSettings.showModal ? false : true - })); - } - - const setModalContent = (newContent: JSX.Element) => { - setSettings(prevSettings => ({...prevSettings, - modalContent: newContent - })); - } - - const [settings, setSettings] = useState({ - ...initialSettings, - toggleStdColor, - toggleModal, - setModalContent - }) - - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState(initialFormData); - const [page, setPage] = useState(firstPage(0)); - const [searchResults, setSearchResults] = useState([]); - const navigate = useNavigate(); - - - - // MappingRecord 3d array -> [][][] list of mappings/genes/isoforms - // mappings : [ - // ... - // genes: [ - // ... - // isoforms: [ -> for can, all fields; for non-can, no INPUT & GENOMIC fields, only PROTEIN fields - // ... - // ] - // ] - // ] - // e.g. - // input 1 gene 1 isoform1 (can) - // isoform2 (non-can) - // input 2 gene 1 isoform 1 (can) - // isoform 2 (non-can) - // ... - // gene 2 isoform 1 (can) - // isoform 2 (non-can) - // ... - // ... - // ... - const fetchPage = (page: Page) => { - setLoading(true); - if (formData.file) { - fetchFromFile(page, formData.file); - } else if (formData.userInputs) { - handleSearch(page, formData.userInputs); - } - }; - - function updateAssembly(assembly: Assembly) { - formData.assembly = assembly; - setFormData(formData); - } - - function fetchPasteResult(userInputString: string) { - const userInputs = userInputString.split(/[\n,;|]/); - formData.userInputs = userInputs; - formData.file = null; - setFormData(formData); - setLoading(true); - handleSearch(firstPage(userInputs.length), userInputs); - } - - const handleSearch = (page: Page, inputArr: string[]) => { - const PAGE_SIZE = page.itemsPerPage; - var skipRecord = (page.currentPage - 1) * PAGE_SIZE; - if (inputArr.length <= skipRecord) return; - - var inputSubArray; - const isNextPage = inputArr.length > skipRecord + PAGE_SIZE; - if (isNextPage) { - inputSubArray = inputArr.slice(skipRecord, skipRecord + PAGE_SIZE); - } else { - inputSubArray = inputArr.slice(skipRecord); - } - - setPage({ ...page, nextPage: isNextPage }); - mappingApiCall(inputSubArray); - }; - - function fetchFileResult(file: File) { - setLoading(true); - formData.file = file; - formData.userInputs = []; - setFormData(formData); - file - .text() - .then((text) => fetchFromFile(firstPage(text.split("\n").length), file)); + const updateState = (key: string, value: any) => { + setAppState(prevState => { + return { + ...prevState, + [key]: value + } + }) } - const fetchFromFile = (page: Page, uploadedFile: File) => { - const pageSize = page.itemsPerPage; - const skipRecord = (page.currentPage - 1) * pageSize; - - uploadedFile - .text() - .then((text) => text.split("\n")) - .then((lines) => { - let count = 0, - recordsProcessed = 0; - const inputText: string[] = []; - for (const newInput of lines) { - if (recordsProcessed >= pageSize) { - break; - } - if ( - count > skipRecord && - newInput.length > 0 && - !newInput.startsWith("#") - ) { - recordsProcessed++; - inputText.push(newInput); - } else { - count++; - } - } - setPage({ ...page, nextPage: recordsProcessed >= pageSize }); - return inputText; - }) - .then((inputs) => mappingApiCall(inputs)); - }; + const [appState, setAppState] = useState({ + ...initialState, + updateState + }); - function mappingApiCall(inputSubArray: string[]) { - mappings(inputSubArray, formData.assembly.toString()) - .then((response) => { - const records = convertApiMappingToTableRecords(response.data); - setSearchResults(records); - response.data.messages.forEach(message => { - if (message.type === INFO) { - Notify.info(message.text) - } else if (message.type === WARN) { - Notify.warn(message.text) - } else if (message.type === ERROR) { - Notify.err(message.text) - } - }); - navigate(SEARCH); - }) - .catch((err) => { - navigate(API_ERROR); - console.log(err); - }) - .finally(() => setLoading(false)); - } - - return ( + return ( + - } /> - } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> + } /> } /> + } /> + ); -} +} \ No newline at end of file diff --git a/src/ui/components/common/InfoPop.tsx b/src/ui/components/common/InfoPop.tsx index d22fec36..4043b7d5 100644 --- a/src/ui/components/common/InfoPop.tsx +++ b/src/ui/components/common/InfoPop.tsx @@ -3,18 +3,25 @@ import "./InfoPop.css" import {AppContext} from "../../App"; function ExampleInfoPop(props: {}) { - const context = useContext(AppContext) + const state = useContext(AppContext) + const toggleModal = () => { + state.updateState("showModal", state.showModal ? false : true); + } + const setModalContent = (newContent: JSX.Element) => { + state.updateState("modalContent", newContent); + } + const openModalX = (event: React.MouseEvent) => { // relative position to trigger event/object // https://stackoverflow.com/questions/3234256/find-mouse-position-relative-to-element console.log(event.pageX) console.log(event.pageY) - context.setModalContent(<>XXX) - context.toggleModal() + setModalContent(<>XXX) + toggleModal() } const openModalY = () => { - context.setModalContent(<>YYY) - context.toggleModal() + setModalContent(<>YYY) + toggleModal() } return <> @@ -24,19 +31,22 @@ function ExampleInfoPop(props: {}) { } export const InfoPop = () => { - const context = useContext(AppContext) + const state = useContext(AppContext) + const toggleModal = () => { + state.updateState("showModal", state.showModal ? false : true); + } return ( - context.showModal ? -
+ state.showModal ? +
{ //
e.stopPropagation()}> }
e.stopPropagation()}> - + × - {context.modalContent} + {state.modalContent}
: diff --git a/src/ui/components/function/FunctionalDataRow.tsx b/src/ui/components/function/FunctionalDataRow.tsx index 7a015b5c..ae480451 100644 --- a/src/ui/components/function/FunctionalDataRow.tsx +++ b/src/ui/components/function/FunctionalDataRow.tsx @@ -3,17 +3,26 @@ import ProteinFunctionTable from './ProteinFunctionTable'; import GeneAndTranslatedSequenceTable from './GeneAndTranslatedSequenceTable'; import ProteinInformationTable from './ProteinInformationTable'; import ResidueRegionTable from './ResidueRegionTable'; -import {MappingRecord} from '../../../utills/Convertor'; +import {TranslatedSequence} from '../../../utills/Convertor'; import ProteinIcon from '../../../images/proteins.svg'; import {FunctionalResponse} from "../../../types/FunctionalResponse"; +import {AMScore, ConservScore, ESMScore, EVEScore} from "../../../types/MappingResponse"; interface FunctionalDataRowProps { functionalData: FunctionalResponse - record: MappingRecord + refAA: string + variantAA: string + ensg: string + ensp: Array + caddScore: string + conservScore: ConservScore + amScore: AMScore + eveScore: EVEScore + esmScore: ESMScore } function FunctionalDataRow(props: FunctionalDataRowProps) { - const { functionalData, record } = props; + const { functionalData, ensg, ensp } = props; return ( @@ -21,10 +30,10 @@ function FunctionalDataRow(props: FunctionalDataRowProps) {
protein icon Functional information
- + - +
diff --git a/src/ui/components/function/FunctionalDetail.tsx b/src/ui/components/function/FunctionalDetail.tsx index cebea13c..8235cab2 100644 --- a/src/ui/components/function/FunctionalDetail.tsx +++ b/src/ui/components/function/FunctionalDetail.tsx @@ -2,18 +2,27 @@ import { useState, useEffect } from 'react'; import NoFunctionalDataRow from './NoFunctionalDataRow'; import FunctionalDataRow from './FunctionalDataRow'; import LoaderRow from '../search/LoaderRow'; -import {MappingRecord} from '../../../utills/Convertor'; +import {TranslatedSequence} from '../../../utills/Convertor'; import {getFunctionalData} from "../../../services/ProtVarService"; import {FunctionalResponse} from "../../../types/FunctionalResponse"; +import {AMScore, ConservScore, ESMScore, EVEScore} from "../../../types/MappingResponse"; interface FunctionalDetailProps { referenceFunctionUri: string - record: MappingRecord + refAA: string + variantAA: string + ensg: string + ensp: Array + caddScore: string + conservScore: ConservScore + amScore: AMScore + eveScore: EVEScore + esmScore: ESMScore } function FunctionalDetail(props: FunctionalDetailProps) { - const { referenceFunctionUri, record } = props; + const { referenceFunctionUri } = props; const [apiData, setApiData] = useState() useEffect(() => { getFunctionalData(referenceFunctionUri).then( @@ -25,7 +34,7 @@ function FunctionalDetail(props: FunctionalDetailProps) { if (!apiData) return else if (apiData.id) - return + return else return ; } diff --git a/src/ui/components/function/ResidueRegionTable.tsx b/src/ui/components/function/ResidueRegionTable.tsx index 680cb5c4..9cd3221c 100644 --- a/src/ui/components/function/ResidueRegionTable.tsx +++ b/src/ui/components/function/ResidueRegionTable.tsx @@ -5,17 +5,26 @@ import Evidences from "./Evidences"; import {ReactComponent as ChevronDownIcon} from "../../../images/chevron-down.svg" import {v1 as uuidv1} from 'uuid'; import {StringVoidFun} from "../../../constants/CommonTypes"; -import {aminoAcid3to1Letter, formatRange} from "../../../utills/Util"; -import {FunctionalResponse, Pocket, Foldx, P2PInteraction, ProteinFeature} from "../../../types/FunctionalResponse"; -import {MappingRecord} from "../../../utills/Convertor"; +import {formatRange} from "../../../utills/Util"; +import {FunctionalResponse, Pocket, P2PInteraction, ProteinFeature} from "../../../types/FunctionalResponse"; +import {TranslatedSequence} from "../../../utills/Convertor"; import {Prediction, PUBMED_ID} from "./prediction/Prediction"; import {pubmedRef} from "../common/Common"; import {Tooltip} from "../common/Tooltip"; import {Dropdown} from "react-dropdown-now"; +import {AMScore, ConservScore, ESMScore, EVEScore} from "../../../types/MappingResponse"; -interface ResidueRegionTableProps { +export interface ResidueRegionTableProps { functionalData: FunctionalResponse - record: MappingRecord + refAA: string + variantAA: string + ensg: string + ensp: Array + caddScore: string + conservScore: ConservScore + amScore: AMScore + eveScore: EVEScore + esmScore: ESMScore } function ResidueRegionTable(props: ResidueRegionTableProps) { @@ -36,7 +45,6 @@ function ResidueRegionTable(props: ResidueRegionTableProps) { regions.push(feature); } }); - const oneLetterVariantAA = aminoAcid3to1Letter(props.record.variantAA!); return @@ -45,7 +53,7 @@ function ResidueRegionTable(props: ResidueRegionTableProps) { + style={{verticalAlign: 'top'}}>{getResidues(residues, props, expandedRowKey, toggleRow)} @@ -55,8 +63,7 @@ function ResidueRegionTable(props: ResidueRegionTableProps) { return EmptyElement } -function getResidues(regions: Array, record: MappingRecord, foldxs: Array, oneLetterVariantAA: string | null, expandedRowKey: string, toggleRow: StringVoidFun) { - let foldxs_ = oneLetterVariantAA ? foldxs.filter(foldx => foldx.mutatedType.toLowerCase() === oneLetterVariantAA) : foldxs +function getResidues(regions: Array, props: ResidueRegionTableProps, expandedRowKey: string, toggleRow: StringVoidFun) { return <> Annotations from UniProt {regions.length === 0 &&
@@ -68,8 +75,8 @@ function getResidues(regions: Array, record: MappingRecord, fold return getFeatureList(region, `residue-${idx}`, expandedRowKey, toggleRow); }) } - - + + } diff --git a/src/ui/components/function/prediction/Prediction.tsx b/src/ui/components/function/prediction/Prediction.tsx index b9a43cb1..0bf88a4b 100644 --- a/src/ui/components/function/prediction/Prediction.tsx +++ b/src/ui/components/function/prediction/Prediction.tsx @@ -1,14 +1,14 @@ -import {MappingRecord} from "../../../../utills/Convertor"; import {ConservPred} from "./ConservPred"; import {AlphaMissensePred} from "./AlphaMissensePred"; import {EvePred} from "./EvePred"; import {EsmPred} from "./EsmPred"; import {FoldxPred} from "./FoldxPred"; -import {Foldx} from "../../../../types/FunctionalResponse"; import {useContext} from "react"; import {AppContext} from "../../../App"; import {CaddScorePred} from "./CaddScorePred"; import {ColourCheckbox} from "../../../modal/ColourCheckbox"; +import {ResidueRegionTableProps} from "../ResidueRegionTable"; +import {aminoAcid3to1Letter} from "../../../../utills/Util"; export type PredAttr = { text: string, @@ -27,21 +27,25 @@ export const PUBMED_ID = { INTERFACES: 36690744 } -export const Prediction = (props: { record: MappingRecord, foldxs: Array }) => { - const {stdColor, toggleStdColor} = useContext(AppContext); +export const Prediction = (props: ResidueRegionTableProps) => { + const state = useContext(AppContext); + const oneLetterVariantAA = aminoAcid3to1Letter(props.variantAA); + const foldxs = props.functionalData.foldxs + let foldxs_ = oneLetterVariantAA ? foldxs.filter(foldx => foldx.mutatedType.toLowerCase() === oneLetterVariantAA) : foldxs + return <>
- + Structure predictions
- + Pathogenicity predictions
- {(!props.record.cadd && !props.record.amScore && !props.record.eveScore && !props.record.esmScore) && + {(!props.caddScore && !props.amScore && !props.eveScore && !props.esmScore) &&
No predictions available for this variant
} - - - - - + + + + + } diff --git a/src/ui/components/result/ResultHistory.css b/src/ui/components/result/ResultHistory.css new file mode 100644 index 00000000..4c79c35a --- /dev/null +++ b/src/ui/components/result/ResultHistory.css @@ -0,0 +1,53 @@ +.item-list { + list-style-type: none; + padding: 0; + max-height: 150px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: darkgrey lightgrey; +} + +/* Works on Chrome, Edge, and Safari */ +.item-list::-webkit-scrollbar { + width: 12px; +} + +.item-list::-webkit-scrollbar-track { + background: lightgrey; +} + +.item-list::-webkit-scrollbar-thumb { + background-color: darkgrey; + border-radius: 20px; + border: 3px solid lightgrey; +} + +.item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 4px 4px 12px; + position: relative; +} + +.item-options { + display: none; +} + +.item:hover .item-options { + display: block; +} +.item:hover { + background-color: lightgrey; +} + +.item-name { + flex-grow: 1; +} +.item-name:hover { + cursor: pointer; +} + +.item-options button { + margin-left: 5px; +} \ No newline at end of file diff --git a/src/ui/components/result/ResultHistory.tsx b/src/ui/components/result/ResultHistory.tsx new file mode 100644 index 00000000..091ddcf9 --- /dev/null +++ b/src/ui/components/result/ResultHistory.tsx @@ -0,0 +1,82 @@ +import "./ResultHistory.css" +import {useEffect, useState} from "react"; +import {LOCAL_RESULTS} from "../../../constants/const"; +import {LOCAL_STORAGE_SET, useLocalStorageContext} from "../../../provider/LocalStorageContextProps"; +import {HOME} from "../../../constants/BrowserPaths"; +import {useNavigate, useParams} from "react-router-dom"; +import {ResultRecord} from "../../../types/ResultRecord"; + +const sortResultsByLatestDate = (records: ResultRecord[]): ResultRecord[] => { + return records.sort((a, b) => { + const getLatestDate = (record: ResultRecord) => { + const dates = [record.firstSubmitted, record.lastSubmitted, record.lastViewed].filter(Boolean).map(date => new Date(date!)); + return dates.length ? Math.max(...dates.map(date => date.getTime())) : 0; + }; + + return getLatestDate(b) - getLatestDate(a); + }); +}; + +const ResultHistory = () => { + const {id} = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const { getValue, setValue } = useLocalStorageContext(); + const [results, setResults] = useState(getValue(LOCAL_RESULTS) || []) + + useEffect(() => { + const handleStorageChange = (e: CustomEvent) => { + if (e.detail === LOCAL_RESULTS) + setResults(getValue(LOCAL_RESULTS) || []); + }; + + // Listen for changes in localStorage + window.addEventListener(LOCAL_STORAGE_SET, handleStorageChange as EventListener); + + return () => { + // Clean up the listener + window.removeEventListener(LOCAL_STORAGE_SET, handleStorageChange as EventListener); + }; + }, [results, getValue]); + + + const saveResults = (updatedRecords: ResultRecord[]) => { + setResults(updatedRecords); + setValue(LOCAL_RESULTS, updatedRecords); + }; + + const deleteResult = (delId: string) => { + const updatedRecords = results.filter(record => record.id !== delId); + saveResults(updatedRecords); + if (id && id === delId) + navigate(HOME) + // TODO API call DELETE /mapping/input/{id} + }; + + const sortedRecords = sortResultsByLatestDate(results); + const shareUrl = `${window.location.origin}${process.env.PUBLIC_URL}` + + return ( +
    + {sortedRecords.map(record => ( +
  • + <> + navigate(record.url)} + className="item-name"> + {`${record.id.slice(0, 6)}...`} + +
    + + +
    + +
  • + ))} +
+ ) +} + +export default ResultHistory; \ No newline at end of file diff --git a/src/ui/components/search/AlternateIsoFormRow.tsx b/src/ui/components/search/AlternateIsoFormRow.tsx index 261e4608..09fc3401 100644 --- a/src/ui/components/search/AlternateIsoFormRow.tsx +++ b/src/ui/components/search/AlternateIsoFormRow.tsx @@ -3,9 +3,10 @@ import { ANNOTATION_COLS, CONSEQUENCES, GENOMIC_COLS } from "../../../constants/ import { MappingRecord } from "../../../utills/Convertor"; import { fullAminoAcidName } from "../../../utills/Util"; import Tool from "../../elements/Tool"; -import { getProteinName } from "./ResultTable"; import Spaces from "../../elements/Spaces"; import { EmptyElement } from "../../../constants/ConstElement"; +import {Gene, GenomicInput, IsoFormMapping} from "../../../types/MappingResponse"; +import {aaChangeStr, rowBg} from "./PrimaryRow"; export function aaChangeTip(change: string | undefined) { return "Amino acid change " + fullAminoAcidName(change?.split("/")[0]) + " -> " + fullAminoAcidName(change?.split("/")[1]); @@ -34,11 +35,41 @@ function AlternateIsoFormRow(props: AlternateIsoFormRowProps) { {record.isoform} -
+ } + +export function getProteinName(proteinName?: string) { + return
{proteinName}
+} + +// V2 +export function getAlternateIsoFormRow(isoformKey: string, index: number, input: GenomicInput, gene: Gene, isoform: IsoFormMapping) { + let aaChange = aaChangeStr(isoform.refAA, isoform.variantAA) + return + + + + + + + +} +// { }; +// V2 +export const NewMsgRow = (props: {msg: Message, input?: InputType}) => { + return + + +}; +// import(/* webpackChunkName: "StructuralDetail" */ "../structure/StructuralDetail")); const PopulationDetail = lazy(() => import(/* webpackChunkName: "PopulationDetail" */ "../population/PopulationDetail")); const FunctionalDetail = lazy(() => import(/* webpackChunkName: "FunctionalDetail" */ "../function/FunctionalDetail")); -const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGroupExpanded: string, toggleIsoFormGroup: StringVoidFun, +export const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGroupExpanded: string, toggleIsoFormGroup: StringVoidFun, annotationExpanded: string, toggleAnnotation: StringVoidFun, hasAltIsoForm: boolean, currStyle: object, stdColor: boolean) => { const caddAttr = caddScoreAttr(record.cadd) @@ -53,23 +61,6 @@ const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGr } } - const getUrl = (id:string) => { - if (id) { - let idLowerCase = id.toLowerCase() - let idUpperCase = id.toUpperCase() - if (idLowerCase.startsWith("rs")) { - return DBSNP_URL + id; - } else if (idUpperCase.startsWith("RCV")) { - return CLINVAR_RCV_URL + id; - } else if (idUpperCase.startsWith("VCV")) { - return CLINVAR_VCV_URL + id; - } else if (idUpperCase.startsWith("COSV") || idUpperCase.startsWith("COSM") || idUpperCase.startsWith("COSN")) { - return COSMIC_URL + id; - } - } - return ""; - } - const positionUrl = ENSEMBL_VIEW_URL + record.chromosome + ':' + record.position + '-' + record.position; const expandedGroup = record.isoform + '-' + record.position + '-' + record.altAllele; const functionalKey = 'functional-' + expandedGroup; @@ -94,7 +85,7 @@ const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGr @@ -137,7 +128,7 @@ const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGr @@ -152,9 +143,9 @@ const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGr @@ -171,15 +162,39 @@ const getPrimaryRow = (record: MappingRecord, toggleOpenGroup: string, isoFormGr } {functionalKey === annotationExpanded && }> - + } }; -function getSignificancesButton(rowKey: string, buttonLabel: string, accession: MappingRecord, +export const getIdUrl = (id:string) => { + if (id) { + let idLowerCase = id.toLowerCase() + let idUpperCase = id.toUpperCase() + if (idLowerCase.startsWith("rs")) { + return DBSNP_URL + id; + } else if (idUpperCase.startsWith("RCV")) { + return CLINVAR_RCV_URL + id; + } else if (idUpperCase.startsWith("VCV")) { + return CLINVAR_VCV_URL + id; + } else if (idUpperCase.startsWith("COSV") || idUpperCase.startsWith("COSM") || idUpperCase.startsWith("COSN")) { + return COSMIC_URL + id; + } + } + return ""; +} + +function getSignificancesButton(rowKey: string, buttonLabel: string, canonical: boolean | undefined, annotationExpanded: string, toggleAnnotation: StringVoidFun) { - if (!accession.canonical) return EmptyElement; + if (!canonical) return EmptyElement; const buttonCss = rowKey === annotationExpanded ? 'button significance' : 'button'; var toolTip = "Click for functional information" var buttonTag = protein icon @@ -202,4 +217,165 @@ function getSignificancesButton(rowKey: string, buttonLabel: string, accession: ); } +// V2 +export const rowBg = (index: number) => { + const rowColor = {backgroundColor: "#F4F3F3" } + const altRowColor = {backgroundColor: "#FFFFFF" } + return (index % 2 === 0) ? altRowColor : rowColor; +} + +export const aaChangeStr = (ref: string, alt: string) => { + return ref + '/' + alt +} + +export const getNewPrimaryRow = (isoformKey: string, isoformGroup: string, isoformGroupExpanded: string, index: number, input: GenomicInput, originalInput: InputType, gene: Gene, isoform: IsoFormMapping, toggleIsoFormGroup: StringVoidFun, + annotationExpanded: string, toggleAnnotation: StringVoidFun, hasAltIsoForm: boolean, stdColor: boolean) => { + + const caddAttr = caddScoreAttr(gene.caddScore?.toString()) + const amAttr = amScoreAttr(isoform.amScore?.amClass) + + let codon = isoform.refCodon + '/' + isoform.variantCodon; + let strand = gene.reverseStrand ? '(-)' : '(+)'; + if (!codon) { + strand = ''; + } + let inputStyle = { + gen: { + backgroundColor: originalInput.type === INPUT_GEN ? "#F8EDF0" : "" + }, + pro: { + backgroundColor: (originalInput.type === INPUT_PRO || originalInput.type === INPUT_CDNA)? "#F8EDF0" : "" + }, + rs: { + backgroundColor: originalInput.type === INPUT_ID ? "#F8EDF0" : "" + } + } + + const positionUrl = ENSEMBL_VIEW_URL + input.chr + ':' + input.pos + '-' + input.pos; + const functionalKey = 'functional-' + isoformKey; + const structuralKey = 'structural-' + isoformKey; + const populationKey = 'population-' + isoformKey; + + let aaChange = aaChangeStr(isoform.refAA, isoform.variantAA) + + let ensp: Array = []; + if (isoform.translatedSequences !== undefined && isoform.translatedSequences.length > 0) { + var ensps: Array = []; + isoform.translatedSequences.forEach((translatedSeq) => { + var ensts: Array = []; + translatedSeq.transcripts.forEach((transcript) => ensts.push(transcript.enst)); + ensps.push({ensp: translatedSeq.ensp, ensts: ensts.join()}); + }); + ensp = ensps; + } + return + + + + + + + + + + + + + + + + + + + {populationKey === annotationExpanded && + }> + + + } + {structuralKey === annotationExpanded && + }> + + + } + {functionalKey === annotationExpanded && + }> + + + } + + +}; +// >> } -export function getProteinName(record: MappingRecord) { - let proteinName = record.proteinName; - if (record.proteinName && record.proteinName.length > 20) { - proteinName = record.proteinName.substring(0, 20) + '...'; - } - return proteinName -} - function ResultTable(props: ResultTableProps) { const stdColor = useContext(AppContext).stdColor; const [isoFormGroupExpanded, setIsoFormGroupExpanded] = useState('') diff --git a/src/ui/layout/DefaultPageContent.tsx b/src/ui/layout/DefaultPageContent.tsx index f3022dc3..5d80f46f 100644 --- a/src/ui/layout/DefaultPageContent.tsx +++ b/src/ui/layout/DefaultPageContent.tsx @@ -1,13 +1,37 @@ import { NavLink } from 'react-router-dom' -import { DOWNLOAD, HOME, SEARCH } from '../../constants/BrowserPaths' -import { MappingRecord } from '../../utills/Convertor' +import {DOWNLOAD, HOME, RESULT} from '../../constants/BrowserPaths' +import {useEffect, useState} from "react"; +import ResultHistory from "../components/result/ResultHistory"; +import {LOCAL_DOWNLOADS, LOCAL_RESULTS} from "../../constants/const"; +import {LOCAL_STORAGE_SET, useLocalStorageContext} from "../../provider/LocalStorageContextProps"; +import {DownloadRecord} from "../../types/DownloadRecord"; +import {ResultRecord} from "../../types/ResultRecord"; const DefaultPageContent = (props: { children: JSX.Element - downloadCount: number - searchResults?: MappingRecord[][][] }) => { - const { children, downloadCount, searchResults } = props + const { children } = props + const { getValue } = useLocalStorageContext(); + const [results, setResults] = useState(getValue(LOCAL_RESULTS) || []) + const [downloads, setDownloads] = useState(getValue(LOCAL_DOWNLOADS) || []) + + useEffect(() => { + const handleStorageChange = (e: CustomEvent) => { + if (e.detail === LOCAL_RESULTS) + setResults(getValue(LOCAL_RESULTS) || []); + else if (e.detail === LOCAL_DOWNLOADS) + setDownloads(getValue(LOCAL_DOWNLOADS) || []) + }; + + // Listen for changes in localStorage + window.addEventListener(LOCAL_STORAGE_SET, handleStorageChange as EventListener); + + return () => { + // Clean up the listener + window.removeEventListener(LOCAL_STORAGE_SET, handleStorageChange as EventListener); + }; + }, [results, getValue]); + return (
@@ -17,13 +41,14 @@ const DefaultPageContent = (props: { Search
  • - {searchResults?.length ? - Results : - Results + {results && results.length > 0 ? + Results
    {results.length}
    : + Results
    {results.length}
    } +
  • - Downloads ({downloadCount}) + Downloads
    {downloads.length}
  • diff --git a/src/ui/layout/DefaultPageLayout.tsx b/src/ui/layout/DefaultPageLayout.tsx index d94e4d7d..d6fa2caf 100644 --- a/src/ui/layout/DefaultPageLayout.tsx +++ b/src/ui/layout/DefaultPageLayout.tsx @@ -1,19 +1,17 @@ import {useEffect, useState} from 'react' import { Link } from 'react-router-dom' import {ABOUT, CONTACT, HELP, HOME, RELEASE} from '../../constants/BrowserPaths' -import { API_URL, LOCAL_DOWNLOADS, DISMISS_BANNER } from '../../constants/const' +import { API_URL, DISMISS_BANNER } from '../../constants/const' import DefaultPageContent from './DefaultPageContent' import EMBLEBILogo from '../../images/embl-ebi-logo.svg' import openTargetsLogo from '../../images/open-targets-logo.png' import SignUp from "./SignUp"; -import { MappingRecord } from '../../utills/Convertor' import {WARN_ICON} from "../components/search/MsgRow"; import {CookieConsent} from "react-cookie-consent"; interface DefaultPageLayoutProps { - content: JSX.Element, - searchResults?: MappingRecord[][][] + content: JSX.Element } const bannerText = "AlphaMissense prediction has replaced EVE score in the main table. You can now find EVE score under Predictions in the Functional Information section." @@ -23,8 +21,6 @@ function DefaultPageLayout(props: DefaultPageLayoutProps) { // to re-enable banner, uncomment state above, and the lines within // the handleDismiss function //const showBanner = false - let localDownloads = JSON.parse(localStorage.getItem(LOCAL_DOWNLOADS) || '[]') - let numDownloads = localDownloads.length; useEffect(() => { const win: any = window @@ -187,7 +183,7 @@ function DefaultPageLayout(props: DefaultPageLayoutProps) { )}
    - + {content}
    diff --git a/src/ui/layout/SignUp.tsx b/src/ui/layout/SignUp.tsx index 62fb174f..2624c080 100644 --- a/src/ui/layout/SignUp.tsx +++ b/src/ui/layout/SignUp.tsx @@ -1,17 +1,17 @@ import { useState } from 'react' -import { SUBSCRIPTION_STATUS } from '../../constants/const' +import {SUBSCRIPTION_STATUS} from '../../constants/const' import axios from "axios"; import Notify from "../elements/Notify"; import {emailValidate} from "../../utills/Validator"; +import {useLocalStorageContext} from "../../provider/LocalStorageContextProps"; function SignUp() { const [email, setEmail] = useState(""); const form_id = "1ehAvWJrstnYdSfl_j9fT3mJIF7w4pztXrjDKfaFTZ_g" const formUrl = "https://docs.google.com/forms/d/" + form_id + "/formResponse" const email_field_name = "entry.857245557" - const [subscriptionStatus, setSubscriptionStatus] = useState( - JSON.parse(localStorage.getItem(SUBSCRIPTION_STATUS) || 'false'), - ) + const { getValue, setValue } = useLocalStorageContext(); + const [subscriptionStatus, setSubscriptionStatus] = useState(getValue(SUBSCRIPTION_STATUS) || false) const handleSubmit = (event: React.FormEvent) => { event.preventDefault() @@ -25,17 +25,17 @@ function SignUp() { axios.post(formUrl, null, {params: params}) .then((response) => { if (response.status === 200) { - localStorage.setItem(SUBSCRIPTION_STATUS, 'true') - setSubscriptionStatus('true') + setValue(SUBSCRIPTION_STATUS, true) + setSubscriptionStatus(true) } }) .catch((_) => { - // CORS issue prevents subscription. Ignoring it for now - // Notify.warn('Could not subscribe. Try later.') + // CORS issue prevents subscription. Ignoring it for now + // Notify.warn('Could not subscribe. Try later.') - // Setting the subscription status anyway for now as we are sure the emails are getting registered as expected. - localStorage.setItem(SUBSCRIPTION_STATUS, 'true') - setSubscriptionStatus('true') + // Setting the subscription status anyway for now as we are sure the emails are getting registered as expected. + setValue(SUBSCRIPTION_STATUS, true) + setSubscriptionStatus(true) }); } } diff --git a/src/ui/modal/ColourCheckbox.tsx b/src/ui/modal/ColourCheckbox.tsx index e47ac3f3..95c6b650 100644 --- a/src/ui/modal/ColourCheckbox.tsx +++ b/src/ui/modal/ColourCheckbox.tsx @@ -1,8 +1,13 @@ import {Tooltip} from "../components/common/Tooltip"; +import {AppState} from "../App"; + +export const ColourCheckbox = (props: {state: AppState}) => { + const toggleStdColor = () => { + props.state.updateState("stdColor", props.state.stdColor ? false: true) + } -export const ColourCheckbox = (props: {stdColor: boolean, toggleStdColor:() => void}) => { return diff --git a/src/ui/modal/DownloadModal.tsx b/src/ui/modal/DownloadModal.tsx index 384db8da..d4e57169 100644 --- a/src/ui/modal/DownloadModal.tsx +++ b/src/ui/modal/DownloadModal.tsx @@ -1,17 +1,23 @@ -import { useState, useCallback, useRef } from 'react'; +import {useState, useCallback, useRef, useContext} from 'react'; import Button from '../elements/form/Button'; import Modal from './Modal'; import { ReactComponent as DownloadIcon } from "../../images/download.svg" import useOnClickOutside from '../../hooks/useOnClickOutside'; -import {processDownload} from './DownloadModalHelper' import { emailValidate } from '../../utills/Validator'; import {FormData} from '../../types/FormData' +import {AppContext, AppState} from "../App"; +import {DownloadRecord} from "../../types/DownloadRecord"; +import {LOCAL_DOWNLOADS} from "../../constants/const"; +import Notify from "../elements/Notify"; +import {downloadFileInput, downloadTextInput} from "../../services/ProtVarService"; +import {useLocalStorageContext} from "../../provider/LocalStorageContextProps"; interface DownloadModalProps { - formData: FormData + formData?: FormData } function DownloadModal(props: DownloadModalProps) { + const state = useContext(AppContext) const { formData } = props; const [showModel, setShowModel] = useState(false) const [email, setEmail] = useState("") @@ -22,6 +28,41 @@ function DownloadModal(props: DownloadModalProps) { const setAllAnnotations = (val: boolean) => setAnnotations({ fun: val, pop: val, str: val }) const downloadModelDiv = useRef(null) useOnClickOutside(downloadModelDiv, useCallback(() => setShowModel(false), [])); + const { getValue, setValue } = useLocalStorageContext(); + + const handleSucc = (downloadRes: DownloadRecord) => { + const downloads = getValue(LOCAL_DOWNLOADS) || [] + const updatedDownloads = [...downloads, downloadRes] + setValue(LOCAL_DOWNLOADS, updatedDownloads) + Notify.sucs(`Job ${downloadRes.downloadId} submitted. Check the Downloads page. `) + } + + const handleErr = () => { + Notify.err(`Job ${jobName} failed. Please try again.`) + } + + const processDownload = (functional: boolean, population: boolean, structure: boolean, + email: string, jobName: string, state: AppState, formData?: FormData) => { + + let file = formData?.file || state.file || null; + let assembly = formData?.assembly?.toString() || state.assembly.toString(); + let userInputs: string[] = []; + + if (!file) { + userInputs = formData?.userInputs || + state.textInput.split(/[\n,]/).filter(i => !i.trimStart().startsWith("#")); + } + + if (file) { + downloadFileInput(file, assembly, email, jobName, functional, population, structure) + .then((response) => handleSucc(response.data)) + .catch(handleErr); + } else { + downloadTextInput(userInputs, assembly, email, jobName, functional, population, structure) + .then((response) => handleSucc(response.data)) + .catch(handleErr); + } + } const handleSubmit = () => { const err = emailValidate(email) @@ -30,7 +71,7 @@ function DownloadModal(props: DownloadModalProps) { return } setShowModel(false) - processDownload(formData, annotations.fun, annotations.pop, annotations.str, email, jobName); + processDownload(annotations.fun, annotations.pop, annotations.str, email, jobName, state, formData); }; return
    {getResidues(residues, props.record, props.functionalData.foldxs, oneLetterVariantAA, expandedRowKey, toggleRow)} {getRegions(regions, props.functionalData.accession, props.functionalData.pockets, props.functionalData.interactions, expandedRowKey, toggleRow)}
    {getProteinName(record)}{getProteinName(record.proteinName)} {record.aaPos} {record.aaChange} {record.consequences}

    + + + + + {isoform.accession} + + {getProteinName(isoform.proteinName)}{isoform.isoformPosition}{aaChange}{isoform.consequences}

    + {getIcon(props.msg)} + {props.input && props.input.inputStr} {props.msg.text} +
    - {record.id} + {record.id} {record.refAllele} {record.altAllele} - {getProteinName(record)} + {getProteinName(record.proteinName)} {record.aaPos} {record.aaChange}
    {!record.canonical && <>

    } - {getSignificancesButton(functionalKey, 'FUN', record, annotationExpanded, toggleAnnotation)} - {getSignificancesButton(populationKey, 'POP', record, annotationExpanded, toggleAnnotation)} - {getSignificancesButton(structuralKey, 'STR', record, annotationExpanded, toggleAnnotation)} + {getSignificancesButton(functionalKey, 'FUN', record.canonical, annotationExpanded, toggleAnnotation)} + {getSignificancesButton(populationKey, 'POP', record.canonical, annotationExpanded, toggleAnnotation)} + {getSignificancesButton(structuralKey, 'STR', record.canonical, annotationExpanded, toggleAnnotation)}
    + + + {input.chr} + + + + + {input.converted && 37→38} + + {input.pos} + + + + {input.id} + {input.ref}{gene.altAllele} + + {gene.geneName} + + +
    + {codon}{strand} +
    +
    + + + {formatCaddScore(gene.caddScore?.toString())} + + + +
    + + + + {isoform.accession} + + {hasAltIsoForm && <> + + toggleIsoFormGroup(isoformGroup) } + className="button button--toggle-isoforms" + tip={isoformGroupExpanded !== isoformGroup ? "Show more isoforms" : "Hide isoforms"} + > + {isoformGroupExpanded !== isoformGroup ? + : } + + } +
    +
    + {getProteinName(isoform.proteinName)} + {isoform.isoformPosition}{aaChange}{isoform.consequences} + + + {formatAMScore(isoform.amScore)} + + + +
    + {!isoform.canonical && <>

    } + {getSignificancesButton(functionalKey, 'FUN', isoform.canonical, annotationExpanded, toggleAnnotation)} + {getSignificancesButton(populationKey, 'POP', isoform.canonical, annotationExpanded, toggleAnnotation)} + {getSignificancesButton(structuralKey, 'STR', isoform.canonical, annotationExpanded, toggleAnnotation)} +
    +
    + + + + + + + + + + + + + + {downloads.map((download, index) => { + return ( + + + + + + + + + + ); })} - , []) - - - useEffect(() => { - localStorage.setItem(LOCAL_DOWNLOADS, JSON.stringify(downloads)); - }, [downloads]) - - return
    - -
    FTP download
    -

    - Bulk pre-computed datasets, separated by data type available to download from the FTP site. -

    - -
    Result download
    -

    - {downloads.length > 0 ? ( - <>{downloads.length} download{downloads.length > 1 ? 's' : ''} -

    #RequestedIDJob nameStatusDownloadDelete
    {index + 1}{download.requested.toLocaleString()}{download.downloadId}{download.jobName} +
    + {downloadStatusText[download.status]}
    + + +
    - - - - - - - - - - - - - - {downloads.map((download, index) => { - return ( - - - - - - - - - - ); - })} - - -
    #RequestedIDJob nameStatusDownloadDelete
    {index + 1}{download.requested.toLocaleString()}{download.downloadId}{download.jobName} -
    - {downloadStatusText[download.status]}
    - - -
    - - ) : ( - `No download` - ) - } -

    - - -
    + + + + + ) : ( + `No download` + ) + } +

    + + +
    } function downloadFile(url: string) { - Notify.info("Downloading file...") - window.open(url, "_blank"); + Notify.info("Downloading file...") + window.open(url, "_blank"); } -function DownloadPage(props: {searchResults: MappingRecord[][][]}) { - return } searchResults={props.searchResults}/> +function DownloadPage() { + return }/> } + export default DownloadPage; \ No newline at end of file diff --git a/src/ui/pages/home/HomePage.tsx b/src/ui/pages/home/HomePage.tsx index e56bb79c..d77844ce 100644 --- a/src/ui/pages/home/HomePage.tsx +++ b/src/ui/pages/home/HomePage.tsx @@ -1,22 +1,11 @@ import DefaultPageLayout from '../../layout/DefaultPageLayout' -import { FileLoadFun } from '../../../utills/AppHelper' -import { Assembly, StringVoidFun } from '../../../constants/CommonTypes' -import { Link } from 'react-router-dom' -import { ABOUT, CONTACT } from '../../../constants/BrowserPaths' +import {Link} from 'react-router-dom' +import {ABOUT, CONTACT} from '../../../constants/BrowserPaths' import {API_URL, TITLE} from '../../../constants/const' import SearchVariant from './SearchVariant' -import { MappingRecord } from '../../../utills/Convertor' -import {FormData} from '../../../types/FormData' import React, {useEffect} from "react"; -const HomePageContent = (props: HomePageProps) => { - const { - loading, - formData, - updateAssembly, - fetchFileResult, - fetchPasteResult, - } = props +const HomePageContent = () => { useEffect(() => { document.title = TITLE; @@ -36,13 +25,7 @@ const HomePageContent = (props: HomePageProps) => {

    - +
    @@ -84,16 +67,7 @@ const HomePageContent = (props: HomePageProps) => { ) } -interface HomePageProps { - loading: boolean - formData: FormData - updateAssembly: (assembly: Assembly) => void - fetchFileResult: FileLoadFun - fetchPasteResult: StringVoidFun - searchResults: MappingRecord[][][] -} - -const HomePage = (props: HomePageProps) => ( - } searchResults={props.searchResults} /> +export const HomePage = () => ( + } /> ) export default HomePage diff --git a/src/ui/pages/home/SearchVariant.tsx b/src/ui/pages/home/SearchVariant.tsx index 21b14623..87a50467 100644 --- a/src/ui/pages/home/SearchVariant.tsx +++ b/src/ui/pages/home/SearchVariant.tsx @@ -1,11 +1,6 @@ -import React, { useState, useRef } from 'react' +import React, {useState, useRef} from 'react' import Button from '../../elements/form/Button' -import { FileLoadFun } from '../../../utills/AppHelper' -import { - Assembly, - DEFAULT_ASSEMBLY, - StringVoidFun, -} from '../../../constants/CommonTypes' +import {Assembly, DEFAULT_ASSEMBLY} from '../../../constants/CommonTypes' import { CDNA_BTN_TITLE, CDNA_EXAMPLE, @@ -15,47 +10,55 @@ import { PASTE_BOX, PROTEIN_BTN_TITLE, PROTEIN_EXAMPLE } from "../../../constants/Example"; +import {Form, initialForm} from "../../../types/FormData"; +import {submitInputFile, submitInputText} from "../../../services/ProtVarService"; +import {API_ERROR, RESULT} from "../../../constants/BrowserPaths"; +import {useNavigate} from "react-router-dom"; +import {AxiosResponse} from "axios"; +import {IDResponse} from "../../../types/PagedMappingResponse"; +import {useLocalStorageContext} from "../../../provider/LocalStorageContextProps"; +import {LOCAL_RESULTS} from "../../../constants/const"; +import {ResultRecord} from "../../../types/ResultRecord"; -interface VariantSearchProps { - isLoading: boolean - assembly: Assembly - updateAssembly: (assembly: Assembly) => void - fetchPasteResult: StringVoidFun - fetchFileResult: FileLoadFun -} - -const SearchVariant = (props: VariantSearchProps) => { - const [searchTerm, setSearchTerm] = useState(''); - const [assembly, setAssembly] = useState(props.assembly); - const [file, setFile] = useState(null); +const SearchVariant = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState
    (initialForm) const [invalidInput, setInvalidInput] = useState(false); const [invalidMsg, setInvalidMsg] = useState(''); const uploadInputField = useRef(null); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const UNSUPPORTED_FILE = 'Unsupported file type'; const FILE_EXCEEDS_LIMIT = 'File exceeds 10MB limit'; + const { getValue, setValue } = useLocalStorageContext(); + const savedRecords = getValue(LOCAL_RESULTS) || []; - const update = (a: Assembly) => { - setAssembly(a); // set assembly in the search variant form - props.updateAssembly(a); // set assembly in the top-level - } + const submittedRecord = (id: string) => { + const now = new Date().toLocaleString(); + const existingRecord = savedRecords.find(record => record.id === id); - const genomicExamples = () => { - setSearchTerm(GENOMIC_EXAMPLE) - }; + let updatedRecords; - const cDNAExamples = () => { - setSearchTerm(CDNA_EXAMPLE) - }; - - const proteinExamples = () => { - setSearchTerm(PROTEIN_EXAMPLE) - update(DEFAULT_ASSEMBLY) - }; - - const idExamples = () => { - setSearchTerm(ID_EXAMPLE) - update(DEFAULT_ASSEMBLY) + if (existingRecord) { + const updatedRecord = { + ...existingRecord, + lastSubmitted: now, + //lastViewed: now + }; + updatedRecords = savedRecords.map(record => + record.id === id ? updatedRecord : record + ); + } else { + const newRecord: ResultRecord = { + id, + url: `${RESULT}/${id}`, + firstSubmitted: now, + //lastSubmitted: now, + //lastViewed: now + }; + updatedRecords = [newRecord, ...savedRecords]; + } + setValue(LOCAL_RESULTS, updatedRecords); }; const viewResult = (event: React.ChangeEvent) => { @@ -71,30 +74,41 @@ const SearchVariant = (props: VariantSearchProps) => { setInvalidInput(true); setInvalidMsg(FILE_EXCEEDS_LIMIT); } else { - setFile(file); + setForm({...form, file: file}) setInvalidInput(false); } - }; - const handleSubmit = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - if (file) { - props.fetchFileResult(file); - // file takes precendence over text search - return; - } - if (searchTerm !== '') { - props.fetchPasteResult(searchTerm) + const handleSubmit = () => { + setLoading(true) + var promise: Promise> | undefined = undefined; + if (form.file) + promise = submitInputFile(form.file, form.assembly) + else if (form.text) + promise = submitInputText(form.text, form.assembly) + + if (promise) { + promise + .then((response) => { + submittedRecord(response.data.id) + let url = `${RESULT}/${response.data.id}` + if (form.assembly !== DEFAULT_ASSEMBLY) + url += `?assembly=${form.assembly}` + navigate(url) + }) + .catch((err) => { + navigate(API_ERROR); + console.log(err); + }) } + setLoading(false); } const clearFileInput = () => { - if ( uploadInputField?.current) { + if (uploadInputField?.current) { const fileInput = uploadInputField.current; fileInput.value = ''; - setFile(null); + setForm({...form, file: null}); } } @@ -114,52 +128,52 @@ const SearchVariant = (props: VariantSearchProps) => {