From f11a40b9957753b6afb7e9405fc5baa9a185a5ca Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Wed, 25 Sep 2024 14:39:15 -0400 Subject: [PATCH] Negative genes input, neutral genes output, CI deploy refactor, and other frontend additions (#49) @ChristopherMancuso please look at the negative genes input and neutral gene results portions. You'll have to run the stack locally. @falquaddoomi please take a look at the deployment workflows. I haven't tested these on a fork yet, and haven't generated the secrets yet either. - format workflow files with prettier - CI deploy the backend and frontend in sequence, in single workflow, so that they are deployed close together in time. make frontend deployment dependent on function deploy success or skip. - add link to old geneplexus - add "negatives" to analysis input type, and "neutral gene info" to analysis output type - tweak select component prop names and styles, and update in-situ usage of them to match - tweak table component. pass row to custom render func, fix expansion css, more descriptive filter labels, fix filter bug - add note about old geneplexus to about page and home page - add "neutrals" tab in analysis results page with basic table view of neutral gene info - add negative genes input text box on new analysis page, and incorporate into "check genes" functionality - add redirect link on 404 page to old site - update cloud functions to take negatives as input and return neutral gene info as output --------- Co-authored-by: Faisal Alquaddoomi --- .../workflows/deploy-func-convert_ids.yaml | 19 -- .github/workflows/deploy-func-ml.yaml | 19 -- ...lper-deploy-func.yaml => deploy-func.yaml} | 64 ++--- .github/workflows/deploy.yaml | 96 +++++++ .github/workflows/test-frontend.yaml | 10 +- .gitignore | 2 +- README.md | 2 + frontend/.env | 3 +- frontend/.prettierrc | 1 + frontend/README.md | 52 ---- frontend/public/404.html | 3 +- frontend/src/api/api.ts | 16 +- frontend/src/api/convert.ts | 133 ++++++++++ frontend/src/api/types.ts | 243 ++++++++---------- frontend/src/components/AnalysisCard.tsx | 2 +- frontend/src/components/Button.tsx | 3 +- frontend/src/components/Popover.module.css | 10 + frontend/src/components/Select.module.css | 10 +- frontend/src/components/SelectMulti.tsx | 24 +- frontend/src/components/SelectSingle.tsx | 16 +- frontend/src/components/Table.module.css | 8 + frontend/src/components/Table.tsx | 86 ++++--- frontend/src/components/TextBox.module.css | 4 - frontend/src/components/TextBox.tsx | 18 +- frontend/src/components/Tooltip.module.css | 10 + frontend/src/components/Tooltip.tsx | 2 +- frontend/src/global/layout.css | 21 +- frontend/src/global/styles.css | 4 +- frontend/src/pages/About.tsx | 25 +- frontend/src/pages/Analysis.tsx | 12 +- frontend/src/pages/Home.tsx | 8 + frontend/src/pages/LoadAnalysis.tsx | 2 +- frontend/src/pages/NewAnalysis.module.css | 12 + frontend/src/pages/NewAnalysis.tsx | 199 +++++++++----- frontend/src/pages/NotFound.tsx | 13 +- frontend/src/pages/Testbed.tsx | 45 ++-- frontend/src/pages/analysis/InputGenes.tsx | 2 +- frontend/src/pages/analysis/Inputs.module.css | 2 +- frontend/src/pages/analysis/Inputs.tsx | 36 +-- frontend/src/pages/analysis/Network.tsx | 10 +- frontend/src/pages/analysis/Neutrals.tsx | 38 +++ frontend/src/pages/analysis/Predictions.tsx | 2 +- frontend/src/pages/analysis/Similarities.tsx | 2 +- frontend/src/pages/analysis/Summary.tsx | 2 +- frontend/src/util/hooks.ts | 2 + frontend/src/util/string.ts | 3 +- functions/README.md | 145 +---------- functions/ml/ml_deploy/main.py | 4 + 48 files changed, 834 insertions(+), 611 deletions(-) delete mode 100644 .github/workflows/deploy-func-convert_ids.yaml delete mode 100644 .github/workflows/deploy-func-ml.yaml rename .github/workflows/{helper-deploy-func.yaml => deploy-func.yaml} (61%) create mode 100644 .github/workflows/deploy.yaml create mode 100644 frontend/src/api/convert.ts create mode 100644 frontend/src/pages/analysis/Neutrals.tsx diff --git a/.github/workflows/deploy-func-convert_ids.yaml b/.github/workflows/deploy-func-convert_ids.yaml deleted file mode 100644 index 59314e6..0000000 --- a/.github/workflows/deploy-func-convert_ids.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: 'Deploy GCP function gpz-convert-ids' -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'functions/convert_ids/convert_ids_deploy/**' - -jobs: - deploy-convert-ids-func: - uses: ./.github/workflows/helper-deploy-func.yaml - with: - func-name: "gpz-convert-ids" - func-src-dir: "functions/convert_ids/convert_ids_deploy" - func-entrypoint: "convert_ids" - func-memory-mb: 1024 - func-data-gcs-url: "gs://geneplexus-func-data/convert-ids/convert-ids_data.tar.gz" - secrets: inherit diff --git a/.github/workflows/deploy-func-ml.yaml b/.github/workflows/deploy-func-ml.yaml deleted file mode 100644 index b55f494..0000000 --- a/.github/workflows/deploy-func-ml.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: 'Deploy GCP function gpz-ml' -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'functions/ml/ml_deploy/**' - -jobs: - deploy-ml-func: - uses: ./.github/workflows/helper-deploy-func.yaml - with: - func-name: "gpz-ml" - func-src-dir: "functions/ml/ml_deploy" - func-entrypoint: "ml" - func-memory-mb: 8192 - func-data-gcs-url: "gs://geneplexus-func-data/ml/ml_data.tar.gz" - secrets: inherit diff --git a/.github/workflows/helper-deploy-func.yaml b/.github/workflows/deploy-func.yaml similarity index 61% rename from .github/workflows/helper-deploy-func.yaml rename to .github/workflows/deploy-func.yaml index 2d1753a..f8a059a 100644 --- a/.github/workflows/helper-deploy-func.yaml +++ b/.github/workflows/deploy-func.yaml @@ -1,4 +1,4 @@ -name: Cloud Function Deploy Helper +name: Deploy cloud function on: workflow_call: @@ -21,7 +21,6 @@ on: func-memory-mb: required: true type: number - func-runtime: default: python311 type: string @@ -35,54 +34,55 @@ on: default: us-central1 type: string func-data-local-path: - default: 'data' + default: data type: string - description: 'Path under func-src-dir where the GCS archive is extracted, default ./data' + description: Path under func-src-dir where the GCS archive is extracted, default ./data secrets: JSON_GCLOUD_SERVICE_ACCOUNT_JSON: required: true + jobs: deploy-cloud-func-helper: - runs-on: 'ubuntu-latest' + runs-on: ubuntu-latest env: - DATA_ARCHIVE_HASH: 'none' + DATA_ARCHIVE_HASH: none steps: - - uses: 'actions/checkout@v4' + - name: Checkout repo + uses: actions/checkout@v4 - name: Authenticate to GCP - uses: 'google-github-actions/auth@v2' + uses: google-github-actions/auth@v2 with: # workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' # service_account: 'cloud-function-deployer@gap-som-dbmi-geneplx-app-p0n.iam.gserviceaccount.com' credentials_json: ${{ secrets.JSON_GCLOUD_SERVICE_ACCOUNT_JSON }} - name: Set up Cloud SDK - uses: 'google-github-actions/setup-gcloud@v2' + uses: google-github-actions/setup-gcloud@v2 with: - version: '>= 363.0.0' + version: ">= 363.0.0" - - name: Get hash of the data archive for this function + - name: Get hash of data archive for func run: | gsutil ls -L ${{ inputs.func-data-gcs-url }} | \ grep "Hash (crc32c)" | \ awk '{printf "DATA_ARCHIVE_HASH=%s",$3}' >> "$GITHUB_ENV" - # if the cached files aren't present, this will defer caching the files - # until the end of a successful run of this workflow. the next time the - # workflow runs, it'll retrieve the cached files from the previous run - # and skip the download step. - # the hash of the current datafile is included as part of the key, so - # that fresh data will be fetched if the datafile has changed in GCS. + # if cached files aren't present, defer caching files until end of + # successful run. next time workflow runs, it'll retrieve cached files + # from previous run and skip download step. hash of current datafile + # included as part of key, so fresh data will be fetched if datafile has + # changed in GCS. - name: Cache existing data folder - id: cache-existing-data + id: cache uses: actions/cache@v4 with: path: ${{ inputs.func-src-dir }}/${{ inputs.func-data-local-path }} key: ${{ inputs.func-name }}-data-${{ env.DATA_ARCHIVE_HASH }} - name: Download function data from GCS - if: steps.cache-existing-data.outputs.cache-hit != 'true' + if: steps.cache.outputs.cache-hit != 'true' run: | gsutil cp ${{ inputs.func-data-gcs-url }} /tmp/data.tar.gz mkdir -p ${{ inputs.func-src-dir }}/${{ inputs.func-data-local-path }} @@ -90,22 +90,22 @@ jobs: rm /tmp/data.tar.gz - name: Check filesystem status - run: | - find ${{ inputs.func-src-dir }} + run: find ${{ inputs.func-src-dir }} - - if: runner.debug == '1' + - name: SSH debug + if: runner.debug == '1' uses: mxschmitt/action-tmate@v3 - name: Deploy function '${{ inputs.func-name }}' to GCP run: | gcloud functions deploy ${{ inputs.func-name }} \ - --gen2 \ - --runtime=${{ inputs.func-runtime }} \ - --project=${{ inputs.project-id }} \ - --region=${{ inputs.region }} \ - --source=${{ inputs.func-src-dir }} \ - --entry-point=${{ inputs.func-entrypoint }} \ - --trigger-http \ - --allow-unauthenticated \ - --memory=${{ inputs.func-memory-mb }}MB \ - --service-account=${{ inputs.func-svc-acct }} + --gen2 \ + --runtime=${{ inputs.func-runtime }} \ + --project=${{ inputs.project-id }} \ + --region=${{ inputs.region }} \ + --source=${{ inputs.func-src-dir }} \ + --entry-point=${{ inputs.func-entrypoint }} \ + --trigger-http \ + --allow-unauthenticated \ + --memory=${{ inputs.func-memory-mb }}MB \ + --service-account=${{ inputs.func-svc-acct }} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..801a1f0 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,96 @@ +name: Deploy backend functions and frontend app + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + path-changes: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + convert_ids: + - "functions/convert_ids/convert_ids_deploy/**" + ml: + - "functions/ml/ml_deploy/**" + outputs: + convert_ids: ${{ steps.changes.outputs.convert_uds }} + ml: ${{ steps.changes.outputs.ml }} + + deploy-convert-ids: + needs: path-changes + if: ${{ needs.path-changes.outputs.convert_ids == 'true' }} + uses: ./.github/workflows/deploy-func.yaml + with: + func-name: "gpz-convert-ids" + func-src-dir: "functions/convert_ids/convert_ids_deploy" + func-entrypoint: "convert_ids" + func-memory-mb: 1024 + func-data-gcs-url: "gs://geneplexus-func-data/convert-ids/convert-ids_data.tar.gz" + secrets: inherit + + deploy-ml: + needs: path-changes + if: ${{ needs.path-changes.outputs.ml == 'true' }} + uses: ./.github/workflows/deploy-func.yaml + with: + func-name: "gpz-ml" + func-src-dir: "functions/ml/ml_deploy" + func-entrypoint: "ml" + func-memory-mb: 8192 + func-data-gcs-url: "gs://geneplexus-func-data/ml/ml_data.tar.gz" + secrets: inherit + + deploy-frontend: + needs: + - deploy-convert-ids + - deploy-ml + # https://github.com/actions/runner/issues/491#issuecomment-850884422 + if: | + always() && + (needs.deploy-convert-ids.result == 'success' || needs.deploy-convert-ids.result == 'skipped') && + (needs.deploy-ml.result == 'success' || needs.deploy-ml.result == 'skipped') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + + - name: Install packages + run: bun install + working-directory: frontend + + - if: runner.debug == '1' + uses: mxschmitt/action-tmate@v3 + + - name: Build + run: bun run build + working-directory: frontend + + - name: Deploy to Netlify + uses: nwtgck/actions-netlify@v3.0 + with: + publish-dir: .frontend/dist + production-branch: main + production-deploy: true + deploy-message: Deploy from GitHub Actions + enable-pull-request-comment: false + enable-commit-comment: false + enable-commit-status: true + overwrites-pull-request-comment: false + enable-github-deployment: false + fails-without-credentials: true + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + timeout-minutes: 1 diff --git a/.github/workflows/test-frontend.yaml b/.github/workflows/test-frontend.yaml index dded001..00be0ca 100644 --- a/.github/workflows/test-frontend.yaml +++ b/.github/workflows/test-frontend.yaml @@ -3,8 +3,8 @@ name: Test Frontend on: pull_request: paths: - - 'frontend/**' - - 'package.json' + - "frontend/**" + - "package.json" defaults: run: @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v1 @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v1 @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Bun uses: oven-sh/setup-bun@v1 diff --git a/.gitignore b/.gitignore index 8809fd8..1c1006e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -*DS_Store +.DS_Store functions/ml/ml_deploy/data/ functions/convert_ids/convert_ids_deploy/data/ diff --git a/README.md b/README.md index 1361b98..64b4636 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ # GenePlexus App v2.0 + +[![Cloud Function Deploy Helper](https://github.com/krishnanlab/geneplexus-app-v2/actions/workflows/deploy.yaml/badge.svg)](https://github.com/krishnanlab/geneplexus-app-v2/actions/workflows/deploy.yaml) [![Netlify Status](https://api.netlify.com/api/v1/badges/aae668a9-01fa-4998-9158-60c92d994598/deploy-status)](https://app.netlify.com/sites/molevolvr/deploys) \ No newline at end of file diff --git a/frontend/.env b/frontend/.env index 63844a2..39bc710 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,4 +1,5 @@ VITE_TITLE=GenePlexus VITE_DESCRIPTION=GenePlexus enables researchers to predict novel genes similar to genes of interest based on their patterns of connectivity in human genome-scale networks. -VITE_URL= +VITE_URL=https://geneplexus.net +VITE_OLD_URL=https://geneplexus-old.net VITE_API=https://us-central1-gap-som-dbmi-geneplx-app-p0n.cloudfunctions.net diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 73e5a78..2377e5a 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -13,6 +13,7 @@ "^./", "^../" ], + "importOrderParserPlugins": ["typescript", "jsx", "importAssertions"], "cssDeclarationSorterOrder": "smacss", "jsdocCapitalizeDescription": false, "htmlWhitespaceSensitivity": "strict" diff --git a/frontend/README.md b/frontend/README.md index 3f3de88..dc74f16 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,55 +25,3 @@ This project was scaffolded using Vite, and has the following key features: ## Usage See the "testbed" page for an overview of what formatting/elements/components/etc. you can use and how. - -## Background - -- **[HTML](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference)**. - Static markup that defines the "content" of a page/document. - Consists of nested [tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element) with [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes) that become the [DOM](https://developer.mozilla.org/en-US/docs/Glossary/DOM). - -- **[CSS](https://developer.mozilla.org/en-US/docs/Web/css/Reference)**. - [Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors) and [styles](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference) that determine how the content of your page looks and is laid out. - [Modules](https://vitejs.dev/guide/features.html#css-modules) should be used to keep styles scoped to a particular file and avoid selector name collisions. - -- **[JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)**. - A dynamic, loosely-typed, general-purpose language that runs in a browser when a user visits a page. - It can dynamically manipulate the document/styles, make network requests, and interact with the browser in [a multitude of other ways](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Client-side_web_APIs). - -_These 👆 are the only things that a browser can natively understand_ (and assets like images, videos, etc.). -Anything else 👇 is abstraction or convenience built on top of them, and must first go through a "compilation"/"build" step to transform it into one of the three core things. - -- **[React](https://react.dev/learn)**. - A widely popular and plugin-rich JavaScript library that makes it easier to generate HTML on the page, keep it in sync with data, handle events, and more. - In general, it makes code more readable, robust, and re-usable. - The [JSX](https://react.dev/learn/writing-markup-with-jsx) syntax is used to write dynamic content in a more declarative, clear way. - Pieces of content are split into cleanly separated "components". - A component is a generic term for any function that returns HTML to be generated, and it can range from low level (e.g. button, select dropdown, table) to high-level (e.g. tab group, section, page). - -- **[TypeScript](https://en.wikipedia.org/wiki/TypeScript)**. - A superset of JavaScript that makes it strongly, statically typed so you can catch errors before even running the code. - Adds some additional overhead/complexity, but is especially essential for apps dealing with complex data structures. - -- **[Vite](https://vitejs.dev/)**. - A multi-purpose tool that handles coordination of the various other technologies. - Most notably, it handles running the app locally for development (with fast automatic refresh), transpiling TypeScript into plain JavaScript, and "compiling" the app into a production/browser-ready bundle. - -- **[Node](https://nodejs.org/en/)**. - An environment for running JavaScript locally instead of in a browser. - Has APIs that browsers cannot have, such as filesystem access, and lacks some of the APIs browsers do have, such as functions to manipulate the DOM (there is no DOM or browser). - That is, not all Node code can run in the browser, and vice-versa. - Vite and other packages that do things locally are ultimately intended to run on top of this. - -- **[Bun](https://bun.sh/)**. - A very new tool that aims to be an all-in-one replacement for Node, Yarn, Vite, and many other tools. - Since it is so new, in this project will only use it as a runtime (replacement for Node) and package manager (replacement for Yarn). - If you encounter issues, install [Node](https://nodejs.org/en) (`v18` or later) and try running the above commands with `npm` instead of `bun`. - Do not use functionality that is Bun-only (not "backwards-compatible" with Node). - -- **[ESLint](https://eslint.org/)**. - A tool that checks JavaScript code for common pitfalls, mostly focused on things that _affect functionality_ rather than code aesthetics. - -- **[Prettier](https://prettier.io/)**. - A tool that formats JavaScript code to make it look pretty. - Useful for enforcing consistency, avoiding bike-shedding, and writing code more quickly without worrying about formatting. - _Only affects code aesthetics_, and should not affect functionality. diff --git a/frontend/public/404.html b/frontend/public/404.html index ce6ff0a..55e5748 100644 --- a/frontend/public/404.html +++ b/frontend/public/404.html @@ -7,8 +7,9 @@ console.debug("With state:", history.state); const { origin, pathname, search, hash } = location; sessionStorage.redirectPath = pathname + search + hash; + // don't fail redirection all together if state exceeds session storage limit try { - // remove entries that could exceed storage limit + // remove entries that will likely exceed storage limit delete history.state.usr.results; sessionStorage.redirectState = JSON.stringify(history.state); } catch (error) { diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index d7c17f7..4f040b7 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -3,21 +3,19 @@ import { convertAnalysisResults, convertConvertIds, revertAnalysisInputs, - type _AnalysisResults, - type _ConvertIds, type AnalysisInputs, - type Species, +} from "@/api/convert"; +import { + type _AnalysisResults, + type _ConvertIdsInputs, + type _ConvertIdsResults, } from "@/api/types"; /** check input list of genes. convert to entrez, check if in-network, etc. */ -export const checkGenes = async ( - genes: string[], - species: Species = "Human", -) => { +export const checkGenes = async (params: _ConvertIdsInputs) => { const headers = new Headers(); headers.append("Content-Type", "application/json"); - const params = { genes, species }; - const response = await request<_ConvertIds>( + const response = await request<_ConvertIdsResults>( `${api}/gpz-convert-ids`, undefined, { method: "POST", headers, body: JSON.stringify(params) }, diff --git a/frontend/src/api/convert.ts b/frontend/src/api/convert.ts new file mode 100644 index 0000000..c4e7060 --- /dev/null +++ b/frontend/src/api/convert.ts @@ -0,0 +1,133 @@ +/** handle conversion between frontend and backend data formats */ + +import type { + _AnalysisInputs, + _AnalysisResults, + _ConvertIdsResults, +} from "@/api/types"; + +/** backend format to frontend format */ +export const convertConvertIds = (backend: _ConvertIdsResults) => { + /** map "couldn't convert" status to easier-to-work-with value */ + for (const row of backend.df_convert_out) + if (row["Entrez ID"].match(/Could Not be mapped to Entrez/i)) + row["Entrez ID"] = ""; + + return { + count: backend.input_count, + success: backend.df_convert_out.filter((row) => row["Entrez ID"]).length, + error: backend.df_convert_out.filter((row) => !row["Entrez ID"]).length, + summary: backend.table_summary.map((row) => ({ + network: row.Network, + positiveGenes: row.PositiveGenes, + totalGenes: row.NetworkGenes, + })), + table: backend.df_convert_out.map((row) => ({ + input: row["Original ID"], + entrez: row["Entrez ID"], + name: row["Gene Name"], + inNetwork: + (row["In BioGRID?"] ?? row["In IMP?"] ?? row["In STRING?"]) === "Y", + })), + }; +}; + +/** backend format to frontend format */ +export const convertAnalysisInputs = (backend: _AnalysisInputs) => ({ + name: backend.name, + genes: backend.genes, + speciesTrain: backend.sp_trn, + speciesTest: backend.sp_tst, + network: backend.net_type, + genesetContext: backend.gsc, + negatives: backend.negatives, +}); + +/** ml endpoint params frontend format */ +export type AnalysisInputs = ReturnType; + +/** frontend format to backend format */ +export const revertAnalysisInputs = ( + frontend: AnalysisInputs, +): _AnalysisInputs => ({ + name: frontend.name, + genes: frontend.genes, + sp_trn: frontend.speciesTrain, + sp_tst: frontend.speciesTest, + net_type: frontend.network, + gsc: frontend.genesetContext, + negatives: frontend.negatives, +}); + +/** backend format to frontend format */ +export const convertAnalysisResults = (backend: _AnalysisResults) => ({ + inputGenes: backend.df_convert_out_subset.map((row) => ({ + input: row["Original ID"], + entrez: row["Entrez ID"].match(/Could Not be mapped to Entrez/i) + ? "" + : row["Entrez ID"], + name: row["Gene Name"], + inNetwork: + (row["In BioGRID?"] ?? row["In IMP?"] ?? row["In STRING?"]) === "Y", + })), + crossValidation: backend.avgps, + positiveGenes: backend.positive_genes, + predictions: backend.df_probs.map((row) => ({ + entrez: row.Entrez, + symbol: row.Symbol, + name: row.Name, + knownNovel: row["Known/Novel"], + classLabel: expandClass(row["Class-Label"]), + probability: row.Probability, + zScore: row["Z-score"], + pAdjusted: row["P-adjusted"], + rank: row.Rank, + })), + similarities: backend.df_sim.map((row) => ({ + task: row.Task, + id: row.ID, + name: row.Name, + similarity: row.Similarity, + zScore: row["Z-score"], + pAdjusted: row["P-adjusted"], + rank: row.Rank, + })), + network: { + nodes: backend.df_probs.map((row) => ({ + entrez: row.Entrez, + symbol: row.Symbol, + name: row.Name, + knownNovel: row["Known/Novel"], + classLabel: expandClass(row["Class-Label"]), + probability: row.Probability, + zScore: row["Z-score"], + pAdjusted: row["P-adjusted"], + rank: row.Rank, + })), + edges: backend.df_edge.map((row) => ({ + source: row.Node1, + target: row.Node2, + weight: row.Weight, + })), + }, + neutralInfo: (() => { + const { "All Neutrals": all, ...sets } = backend.neutral_gene_info; + return { + all: Array.isArray(all) ? all : [], + sets: Object.entries(sets).flatMap(([Id, value]) => + Array.isArray(value) ? [] : { Id, ...value }, + ), + }; + })(), +}); + +/** ml endpoint params frontend format */ +export type AnalysisResults = ReturnType; + +/** convert class label abbreviation to full text */ +const expandClass = ( + abbrev: _AnalysisResults["df_probs"][number]["Class-Label"], +) => (({ P: "Positive", N: "Negative", U: "Neutral" }) as const)[abbrev]; + +/** full analysis */ +export type Analysis = { inputs: AnalysisInputs; results: AnalysisResults }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 732a168..4f6e0c1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,8 +1,3 @@ -/** - * handle data schemas/formats and conversion between them between - * frontend/backend - */ - /** species options */ export type Species = | "Human" @@ -18,187 +13,159 @@ export type Network = "BioGRID" | "STRING" | "IMP"; /** geneset context options */ export type GenesetContext = "GO" | "Monarch" | "Mondo" | "Combined"; -/** convert-ids endpoint response format */ -export type _ConvertIds = { +/** convert-ids endpoint inputs format */ +export type _ConvertIdsInputs = { + /** list of gene symbols/names/ids */ + genes: string[]; + /** species to lookup genes against */ + species: Species; +}; + +/** convert-ids endpoint results format */ +export type _ConvertIdsResults = { + /** number of genes inputted (integer) */ input_count: number; + /** list of successfully converted Entrez IDs */ convert_ids: string[]; + /** high level summary of conversion results, per network */ table_summary: { + /** network */ Network: Network; + /** total number of genes in network (integer) */ NetworkGenes: number; + /** number of input genes in network (integer) */ PositiveGenes: number; }[]; + /** dataframe of results */ df_convert_out: { + /** input id of gene */ "Original ID": string; + /** converted id of gene */ "Entrez ID": string; + /** converted name of gene */ "Gene Name": string; - "In BioGRID?": string; - "In IMP?": string; - "In STRING?": string; + /** whether gene was found in each network */ + "In BioGRID?": "Y" | "N"; + "In IMP?": "Y" | "N"; + "In STRING?": "Y" | "N"; }[]; }; -/** backend format to frontend format */ -export const convertConvertIds = (backend: _ConvertIds) => { - /** map "couldn't convert" status to easier-to-work-with value */ - for (const row of backend.df_convert_out) - if (row["Entrez ID"].match(/Could Not be mapped to Entrez/i)) - row["Entrez ID"] = ""; - - return { - count: backend.input_count, - success: backend.df_convert_out.filter((row) => row["Entrez ID"]).length, - error: backend.df_convert_out.filter((row) => !row["Entrez ID"]).length, - summary: backend.table_summary.map((row) => ({ - network: row.Network, - positiveGenes: row.PositiveGenes, - totalGenes: row.NetworkGenes, - })), - table: backend.df_convert_out.map((row) => ({ - input: row["Original ID"], - entrez: row["Entrez ID"], - name: row["Gene Name"], - inNetwork: - (row["In BioGRID?"] ?? row["In IMP?"] ?? row["In STRING?"]) === "Y", - })), - }; -}; - -/** ml endpoint params backend format */ +/** ml endpoint inputs format */ export type _AnalysisInputs = { + /** human-readable name to remember the analysis by */ name: string; + /** list of gene symbols/names/ids */ genes: string[]; + /** species to lookup genes against */ sp_trn: Species; + /** species for which model predictions will be made */ sp_tst: Species; + /** + * network that ML features are from and which edge list is used to make final + * graph + */ net_type: Network; + /** + * source used to select negative genes and which sets to compare trained + * model to + */ gsc: GenesetContext; + /** genes to force as negative training examples */ + negatives: string[]; }; -/** backend format to frontend format */ -export const convertAnalysisInputs = (backend: _AnalysisInputs) => ({ - name: backend.name, - genes: backend.genes, - speciesTrain: backend.sp_trn, - speciesTest: backend.sp_tst, - network: backend.net_type, - genesetContext: backend.gsc, -}); - -/** ml endpoint params frontend format */ -export type AnalysisInputs = ReturnType; - -/** frontend format to backend format */ -export const revertAnalysisInputs = ( - frontend: AnalysisInputs, -): _AnalysisInputs => ({ - name: frontend.name, - genes: frontend.genes, - sp_trn: frontend.speciesTrain, - sp_tst: frontend.speciesTest, - net_type: frontend.network, - gsc: frontend.genesetContext, -}); - -/** convert-ids endpoint response format */ +/** ml endpoint results format */ export type _AnalysisResults = { - df_convert_out_subset: { - "Original ID": string; - "Entrez ID": string; - "Gene Name": string; - "In BioGRID?"?: string; - "In IMP?"?: string; - "In STRING?"?: string; - }[]; + /** copy of inputs for re-uploading convenience */ + input: _AnalysisInputs; + + /** see `convert-ids` `df_convert_out` schema */ + df_convert_out_subset: _ConvertIdsResults["df_convert_out"]; + /** cross validation results, performance measured using log2(auprc/prior) */ avgps: (number | null | undefined)[]; + /** number of genes considered positives in network (integer) */ positive_genes: number; + /** + * top predicted genes that are isolated from other top predicted genes in + * network (as Entrez IDs) + */ isolated_genes: string[]; + /** + * top predicted genes that are isolated from other top predicted genes in + * network (as gene symbols) + */ isolated_genes_sym: string[]; + /** + * edge list corresponding to subgraph induced by top predicted genes (as + * Entrez IDs) + */ df_edge: { Node1: string; Node2: string; Weight: number }[]; + /** + * edge list corresponding to subgraph induced by top predicted genes (as gene + * symbols) + */ df_edge_sym: { Node1: string; Node2: string; Weight: number }[]; + /** + * table showing how associated each gene in prediction species network is to + * the users gene list + */ df_probs: { + /** rank of relevance of gene to input gene list (integer) */ Rank: number; + /** Entrez ID */ Entrez: string; + /** gene symbol */ Symbol: string; + /** full gene name */ Name: string; + /** whether gene is in input gene list */ "Known/Novel": "Known" | "Novel"; + /** gene class, positive | negative | neutral */ "Class-Label": "P" | "N" | "U"; + /** probability of gene being part of input gene list */ Probability: number; + /** z-score of the probabilities */ "Z-score": number; + /** adjusted p-values of the z-scores */ "P-adjusted": number; }[]; + /** + * table showing how similar user's trained model is to models trained on + * known gene sets + */ df_sim: { + /** + * rank of similarity between input model and a model trained on term gene + * set (integer) + */ Rank: number; + /** type of term */ Task: string; + /** term ID */ ID: string; + /** term name */ Name: string; + /** similarity between input model and a model trained on term gene set */ Similarity: number; + /** z-score of the similarities */ "Z-score": number; + /** adjusted p-values of the z-scores */ "P-adjusted": number; }[]; -}; - -/** backend format to frontend format */ -export const convertAnalysisResults = (backend: _AnalysisResults) => ({ - inputGenes: backend.df_convert_out_subset.map((row) => ({ - input: row["Original ID"], - entrez: row["Entrez ID"].match(/Could Not be mapped to Entrez/i) - ? "" - : row["Entrez ID"], - name: row["Gene Name"], - inNetwork: - (row["In BioGRID?"] ?? row["In IMP?"] ?? row["In STRING?"]) === "Y", - })), - crossValidation: backend.avgps, - positiveGenes: backend.positive_genes, - predictions: backend.df_probs.map((row) => ({ - entrez: row.Entrez, - symbol: row.Symbol, - name: row.Name, - knownNovel: row["Known/Novel"], - classLabel: expandClass(row["Class-Label"]), - probability: row.Probability, - zScore: row["Z-score"], - pAdjusted: row["P-adjusted"], - rank: row.Rank, - })), - similarities: backend.df_sim.map((row) => ({ - task: row.Task, - id: row.ID, - name: row.Name, - similarity: row.Similarity, - zScore: row["Z-score"], - pAdjusted: row["P-adjusted"], - rank: row.Rank, - })), - network: { - nodes: backend.df_probs.map((row) => ({ - entrez: row.Entrez, - symbol: row.Symbol, - name: row.Name, - knownNovel: row["Known/Novel"], - classLabel: expandClass(row["Class-Label"]), - probability: row.Probability, - zScore: row["Z-score"], - pAdjusted: row["P-adjusted"], - rank: row.Rank, - })), - edges: backend.df_edge.map((row) => ({ - source: row.Node1, - target: row.Node2, - weight: row.Weight, - })), - }, -}); - -/** ml endpoint params frontend format */ -export type AnalysisResults = ReturnType; - -/** convert class label abbreviation to full text */ -const expandClass = ( - abbrev: _AnalysisResults["df_probs"][number]["Class-Label"], -) => (({ P: "Positive", N: "Negative", U: "Neutral" }) as const)[abbrev]; - -/** full analysis */ -export type Analysis = { - inputs: AnalysisInputs; - results: AnalysisResults; + /** extra info about genes marked as neutral */ + neutral_gene_info: Record< + /** set that genes are from */ + string, + /** list of gene IDs */ + | string[] + | { + /** list of gene IDs */ + Genes: string[]; + /** term name */ + Name: string; + /** type of term */ + Task: string; + } + >; }; diff --git a/frontend/src/components/AnalysisCard.tsx b/frontend/src/components/AnalysisCard.tsx index 9dc968d..eff472c 100755 --- a/frontend/src/components/AnalysisCard.tsx +++ b/frontend/src/components/AnalysisCard.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import clsx from "clsx"; -import type { AnalysisInputs } from "@/api/types"; +import type { AnalysisInputs } from "@/api/convert"; import Link from "@/components/Link"; import { carve } from "@/util/array"; import { formatNumber } from "@/util/string"; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 77c1191..3309e8c 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -32,7 +32,6 @@ type _Button = Pick< ComponentProps<"button">, | "type" | "onClick" - | "onClickCapture" | "onDrag" | "onDragEnter" | "onDragLeave" @@ -67,8 +66,8 @@ const Button = forwardRef( ) : ( <> - {icon} {text} + {icon} ); diff --git a/frontend/src/components/Popover.module.css b/frontend/src/components/Popover.module.css index 83ba6db..13f1191 100644 --- a/frontend/src/components/Popover.module.css +++ b/frontend/src/components/Popover.module.css @@ -13,3 +13,13 @@ .arrow { fill: var(--white); } + +.content[data-state="open"] { + animation: fade var(--fast); +} + +@keyframes fade { + from { + opacity: 0; + } +} diff --git a/frontend/src/components/Select.module.css b/frontend/src/components/Select.module.css index e6739f5..b0ad4dc 100644 --- a/frontend/src/components/Select.module.css +++ b/frontend/src/components/Select.module.css @@ -75,13 +75,19 @@ opacity: 0; } -.text { +.primary { + display: flex; flex-grow: 2; + align-items: center; } -.info { +.secondary { + display: flex; flex-grow: 1; + align-items: center; + justify-content: flex-end; justify-self: flex-end; + font-size: 0.9rem; text-align: right; } diff --git a/frontend/src/components/SelectMulti.tsx b/frontend/src/components/SelectMulti.tsx index 6fb3bcf..1e7d16c 100644 --- a/frontend/src/components/SelectMulti.tsx +++ b/frontend/src/components/SelectMulti.tsx @@ -16,10 +16,10 @@ import classes from "./Select.module.css"; export type Option = { /** unique id */ id: ID; - /** text label */ - text: string; - /** secondary text */ - info?: string; + /** primary label */ + primary: ReactNode; + /** secondary label */ + secondary?: ReactNode; /** icon */ icon?: ReactElement; }; @@ -73,13 +73,13 @@ const SelectMulti = ({ } > {({ value }) => { - let selectedLabel = ""; + let selectedLabel: ReactNode = ""; const count = value.length; - if (count === 0) selectedLabel = "None"; + if (count === 0) selectedLabel = "None selected"; else if (count === 1) selectedLabel = - options.find((option) => option.id === value[0])?.text || ""; - else if (count === options.length) selectedLabel = "All"; + options.find((option) => option.id === value[0])?.primary || ""; + else if (count === options.length) selectedLabel = "All selected"; else selectedLabel = count + " selected"; return ( @@ -115,9 +115,9 @@ const SelectMulti = ({ className={classes.check} style={{ opacity: selected ? 1 : 0 }} /> - {option.text} - - {option.info} + {option.primary} + + {option.secondary} {option.icon && cloneElement(option.icon, { @@ -142,7 +142,7 @@ const SelectMulti = ({ > {options.map((option, index) => ( ))} diff --git a/frontend/src/components/SelectSingle.tsx b/frontend/src/components/SelectSingle.tsx index 099b487..7c93cd5 100644 --- a/frontend/src/components/SelectSingle.tsx +++ b/frontend/src/components/SelectSingle.tsx @@ -25,10 +25,10 @@ import classes from "./Select.module.css"; export type Option = { /** unique id */ id: ID; - /** text label */ - text: string; - /** secondary text */ - info?: string; + /** primary label */ + primary: ReactNode; + /** secondary label */ + secondary?: ReactNode; /** icon */ icon?: ReactElement; }; @@ -122,7 +122,7 @@ const SelectSingle = ({ }} > - {options.find((option) => option.id === selectedWFallback)?.text} + {options.find((option) => option.id === selectedWFallback)?.primary} @@ -148,9 +148,9 @@ const SelectSingle = ({ style={{ opacity: selected ? 1 : 0 }} /> {/* text */} - {option.text} - - {option.info} + {option.primary} + + {option.secondary} {/* icon */} {option.icon && diff --git a/frontend/src/components/Table.module.css b/frontend/src/components/Table.module.css index a96df34..01347b3 100644 --- a/frontend/src/components/Table.module.css +++ b/frontend/src/components/Table.module.css @@ -1,3 +1,11 @@ +.collapsed { + max-width: 100%; +} + +.expanded { + max-width: calc(100vw - 40px); +} + .scroll { max-width: 100%; overflow-x: auto; diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx index 557e93f..f0ffd3d 100644 --- a/frontend/src/components/Table.tsx +++ b/frontend/src/components/Table.tsx @@ -15,9 +15,14 @@ import { FaSortUp, } from "react-icons/fa6"; import { MdFilterAltOff } from "react-icons/md"; -import clsx from "clsx"; import { clamp, isEqual, pick, sortBy, sum } from "lodash"; -import type { Column, FilterFn, NoInfer } from "@tanstack/react-table"; +import { useLocalStorage } from "@reactuses/core"; +import type { + Column, + FilterFn, + NoInfer, + SortingState, +} from "@tanstack/react-table"; import { createColumnHelper, flexRender, @@ -73,11 +78,11 @@ type Col< * custom render function for cell. return undefined or null to fallback to * default formatting. */ - render?: (cell: NoInfer) => ReactNode; + render?: (cell: NoInfer, row: Datum) => ReactNode; }; /** - * https://stackoverflow.com/questions/68274805/typescript-reference-type-of-property-by-other-property-of-same-object + * https://stackoverflow.com/quezstions/68274805/typescript-reference-type-of-property-by-other-property-of-same-object * https://github.com/vuejs/core/discussions/8851 */ type _Col = { @@ -87,6 +92,7 @@ type _Col = { type Props = { cols: _Col[]; rows: Datum[]; + sort?: SortingState; }; /** map column definition to multi-select option */ @@ -95,7 +101,7 @@ const colToOption = ( index: number, ): Option => ({ id: String(index), - text: col.name, + primary: col.name, }); /** @@ -104,9 +110,9 @@ const colToOption = ( * reference: * https://codesandbox.io/p/devbox/tanstack-table-example-kitchen-sink-vv4871 */ -const Table = ({ cols, rows }: Props) => { +const Table = ({ cols, rows, sort }: Props) => { /** expanded state */ - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useLocalStorage("table-expanded", false); /** column visibility options for multi-select */ const visibleOptions = cols.map(colToOption); @@ -123,12 +129,12 @@ const Table = ({ cols, rows }: Props) => { /** per page options */ const perPageOptions = [ - { id: "5", text: 5 }, - { id: "10", text: 10 }, - { id: "50", text: 50 }, - { id: "100", text: 100 }, - { id: "500", text: 500 }, - ].map((option) => ({ ...option, text: formatNumber(option.text) })); + { id: "5", primary: formatNumber(5) }, + { id: "10", primary: formatNumber(10) }, + { id: "50", primary: formatNumber(50) }, + { id: "100", primary: formatNumber(100) }, + { id: "500", primary: formatNumber(500) }, + ]; /** initial per page */ const defaultPerPage = perPageOptions[1]!.id; @@ -215,9 +221,9 @@ const Table = ({ cols, rows }: Props) => { /** func to use for filtering individual column */ filterFn: filterFunc, /** render func for cell */ - cell: (cell) => { + cell: ({ cell, row }) => { const raw = cell.getValue(); - const rendered = col.render?.(raw); + const rendered = col.render?.(raw, row.original); return rendered === undefined || rendered === null ? defaultFormat(raw) : rendered; @@ -242,7 +248,7 @@ const Table = ({ cols, rows }: Props) => { columnResizeMode: "onChange", /** initial sort, page, etc. state */ initialState: { - sorting: [{ id: "0", desc: false }], + sorting: sort ?? [{ id: "0", desc: false }], pagination: { pageIndex: 0, pageSize: Number(defaultPerPage), @@ -263,8 +269,11 @@ const Table = ({ cols, rows }: Props) => { }); return ( - -
+ +
{/* table */} ({ cols, rows }: Props) => { )} - {/* header tooltip */} + {/*z header tooltip */} {getCol(header.column.id)?.tooltip && ( )} @@ -393,7 +402,7 @@ const Table = ({ cols, rows }: Props) => { {/* controls */} - + {/* pagination */} ); - else if (icon) sideElement =
{icon}
; + else if (icon) + sideElement = ( +
+ {icon} +
+ ); + + /** extra padding needed for side element */ + const sidePadding = useElementBounding(side).width; /** link to parent form component */ const form = useForm(); @@ -102,7 +112,8 @@ const TextBox = ({