From 7126afa972e5fd325498001c46d23b0ca2ea0c24 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 5 Aug 2026 13:57:26 -0700 Subject: [PATCH 1/4] refactor(ui): move error-response helpers into src/lib/errors - Extract `getErrorResponse` out of `api/mavedb/index.ts` and add `describeRequestError` alongside it in the new `src/lib/errors.ts`, replacing the ad hoc `extractErrorDetail` in ScoreSetCalibrationsView - Update all call sites to import from `@/lib/errors` instead of `@/api/mavedb` - Add unit tests covering both exported functions --- src/api/mavedb/index.ts | 15 ----- .../collection/CollectionDataSetEditor.vue | 2 +- src/components/screens/CollectionView.vue | 2 +- src/components/screens/ExperimentCreator.vue | 3 +- src/components/screens/ExperimentEditor.vue | 3 +- .../screens/ScoreSetCalibrationsView.vue | 23 ++----- src/components/screens/ScoreSetCreator.vue | 3 +- src/components/screens/ScoreSetEditor.vue | 3 +- .../screens/SearchVariantsScreen.vue | 3 +- src/components/screens/SettingsScreen.vue | 11 +--- src/lib/errors.test.ts | 65 +++++++++++++++++++ src/lib/errors.ts | 43 ++++++++++++ src/lib/genes.ts | 3 +- src/lib/orcid.ts | 2 +- 14 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 src/lib/errors.test.ts create mode 100644 src/lib/errors.ts diff --git a/src/api/mavedb/index.ts b/src/api/mavedb/index.ts index c097c748..3a92e5cf 100644 --- a/src/api/mavedb/index.ts +++ b/src/api/mavedb/index.ts @@ -1,18 +1,3 @@ -import {isAxiosError} from 'axios' - -export interface ErrorResponse { - status: number - data?: Record -} - -/** Extract a normalized response from a caught Axios error. */ -export function getErrorResponse(e: unknown): ErrorResponse { - if (isAxiosError(e) && e.response) { - return {status: e.response.status, data: e.response.data as Record} - } - return {status: 500} -} - export * from './access-keys' export * from './calibrations' export * from './collections' diff --git a/src/components/collection/CollectionDataSetEditor.vue b/src/components/collection/CollectionDataSetEditor.vue index 5e962885..9a4c997f 100644 --- a/src/components/collection/CollectionDataSetEditor.vue +++ b/src/components/collection/CollectionDataSetEditor.vue @@ -101,7 +101,7 @@ import useItem from '@/composition/item.ts' import {addCollectionScoreSet, addCollectionExperiment} from '@/api/mavedb/collections' import {getScoreSet} from '@/api/mavedb/variants' import {getExperiment} from '@/api/mavedb/experiments' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import {type DataSetType, DATA_SET_TYPE_LABELS} from '@/lib/collections' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import {components} from '@/schema/openapi' diff --git a/src/components/screens/CollectionView.vue b/src/components/screens/CollectionView.vue index e5f9afa7..78bfd8fa 100644 --- a/src/components/screens/CollectionView.vue +++ b/src/components/screens/CollectionView.vue @@ -258,7 +258,7 @@ import {deleteCollection, removeCollectionEntity, updateCollection} from '@/api/ import {useDatasetPermissions} from '@/composables/use-dataset-permissions' import type {RowAction} from '@/components/common/MvRowActionMenu.vue' import {components} from '@/schema/openapi' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' type Collection = components['schemas']['Collection'] type User = components['schemas']['User'] diff --git a/src/components/screens/ExperimentCreator.vue b/src/components/screens/ExperimentCreator.vue index 4d11926d..407d70a0 100644 --- a/src/components/screens/ExperimentCreator.vue +++ b/src/components/screens/ExperimentCreator.vue @@ -172,7 +172,8 @@ import useScopedId from '@/composables/scoped-id' import {useExperimentKeywords} from '@/composables/use-experiment-keywords' import {useJsonFileField} from '@/composables/use-json-file-field' import {usePublicationIdentifiers} from '@/composables/use-publication-identifiers' -import {createExperiment, getErrorResponse} from '@/api/mavedb' +import {createExperiment} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import useAuth from '@/composition/auth' import {useKeywordOptions} from '@/composables/use-keyword-options' import {normalizeDoiArray, normalizeRawReadArray, normalizeContributorArray} from '@/lib/form-helpers' diff --git a/src/components/screens/ExperimentEditor.vue b/src/components/screens/ExperimentEditor.vue index 590165a9..940cb461 100644 --- a/src/components/screens/ExperimentEditor.vue +++ b/src/components/screens/ExperimentEditor.vue @@ -111,7 +111,8 @@ import {useExperimentKeywords} from '@/composables/use-experiment-keywords' import {useJsonFileField} from '@/composables/use-json-file-field' import {usePublicationIdentifiers, type PublicationIdentifier} from '@/composables/use-publication-identifiers' import useAuth from '@/composition/auth' -import {updateExperiment, getErrorResponse} from '@/api/mavedb' +import {updateExperiment} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import useItem from '@/composition/item.ts' import {useKeywordOptions} from '@/composables/use-keyword-options' import {normalizeDoiArray, normalizeRawReadArray, normalizeContributorArray} from '@/lib/form-helpers' diff --git a/src/components/screens/ScoreSetCalibrationsView.vue b/src/components/screens/ScoreSetCalibrationsView.vue index 983695a3..373c2be7 100644 --- a/src/components/screens/ScoreSetCalibrationsView.vue +++ b/src/components/screens/ScoreSetCalibrationsView.vue @@ -232,7 +232,6 @@ import { getScoreSetCalibrations } from '@/api/mavedb' import {useDatasetPermissions} from '@/composables/use-dataset-permissions' -import axios from 'axios' import Button from 'primevue/button' import Column from 'primevue/column' import DataTable from 'primevue/datatable' @@ -242,6 +241,7 @@ import useAuth from '@/composition/auth' import {useCalibrationDialog} from '@/composables/use-calibration-dialog' import MvPageLoading from '@/components/common/MvPageLoading.vue' import CalibrationTable from '@/components/calibration/CalibrationTable.vue' +import {describeRequestError} from '@/lib/errors' import {getScoreSetShortName} from '@/lib/score-sets' import {useConfirm} from 'primevue/useconfirm' import CalibrationEditor from '@/components/calibration/CalibrationEditor.vue' @@ -258,17 +258,6 @@ type ScoreSet = components['schemas']['ScoreSet'] const CALIBRATION_ACTIONS = ['update', 'delete', 'publish', 'change_rank'] as const type CalibrationAuthorizations = Record<(typeof CALIBRATION_ACTIONS)[number], boolean> -function extractErrorDetail(error: unknown): string { - if ( - axios.isAxiosError(error) && - error.response?.data?.detail && - (typeof error.response.data.detail === 'string' || error.response.data.detail instanceof String) - ) { - return error.response.data.detail as string - } - return String(error) -} - export default { name: 'ScoreSetCalibrationsView', components: { @@ -510,7 +499,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Published', - detail: `An error occurred while publishing the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while publishing the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -534,7 +523,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Demoted', - detail: `An error occurred while demoting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while demoting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -555,7 +544,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Promoted', - detail: `An error occurred while promoting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while promoting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -583,7 +572,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Deleted', - detail: `An error occurred while deleting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while deleting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -610,7 +599,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Download Failed', - detail: `An error occurred while downloading the calibrations: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while downloading the calibrations: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } diff --git a/src/components/screens/ScoreSetCreator.vue b/src/components/screens/ScoreSetCreator.vue index 9d65a6cc..7a87d88e 100644 --- a/src/components/screens/ScoreSetCreator.vue +++ b/src/components/screens/ScoreSetCreator.vue @@ -405,7 +405,8 @@ import ToggleSwitch from 'primevue/toggleswitch' import {ref} from 'vue' import {useHead} from '@unhead/vue' -import {getExperiment, searchMyExperiments, createScoreSet, uploadVariantData, getErrorResponse} from '@/api/mavedb' +import {getExperiment, searchMyExperiments, createScoreSet, uploadVariantData} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import CalibrationEditor from '@/components/calibration/CalibrationEditor.vue' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import ScoreSetContextFields from '@/components/forms/ScoreSetContextFields.vue' diff --git a/src/components/screens/ScoreSetEditor.vue b/src/components/screens/ScoreSetEditor.vue index 02762aee..49fbf953 100644 --- a/src/components/screens/ScoreSetEditor.vue +++ b/src/components/screens/ScoreSetEditor.vue @@ -257,7 +257,8 @@ import {useHead} from '@unhead/vue' import ToggleSwitch from 'primevue/toggleswitch' -import {searchMyExperiments, updateScoreSetWithVariants, getErrorResponse} from '@/api/mavedb' +import {searchMyExperiments, updateScoreSetWithVariants} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import MvEmptyState from '@/components/common/MvEmptyState.vue' import ScoreSetContextFields from '@/components/forms/ScoreSetContextFields.vue' diff --git a/src/components/screens/SearchVariantsScreen.vue b/src/components/screens/SearchVariantsScreen.vue index aba122a6..6678fc13 100644 --- a/src/components/screens/SearchVariantsScreen.vue +++ b/src/components/screens/SearchVariantsScreen.vue @@ -778,7 +778,8 @@ import { getAlleleByGnomad, getGeneBySymbol } from '@/api/clingen' -import {getCollection, getErrorResponse, lookupVariantsByClingenId} from '@/api/mavedb' +import {getCollection, lookupVariantsByClingenId} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import {lookupVariantsByVrsDigest} from '@/api/mavedb/variants' import {useEntityCache} from '@/composables/entity-cache' import MvLoader from '@/components/common/MvLoader.vue' diff --git a/src/components/screens/SettingsScreen.vue b/src/components/screens/SettingsScreen.vue index ccd13d48..cade549b 100644 --- a/src/components/screens/SettingsScreen.vue +++ b/src/components/screens/SettingsScreen.vue @@ -153,15 +153,8 @@ import {defineComponent} from 'vue' import {useHead} from '@unhead/vue' import {useRouter} from 'vue-router' -import { - createAccessKey, - createRoleAccessKey, - deleteAccessKey, - getErrorResponse, - getMyCollections, - searchMyExperiments, - searchMyScoreSets -} from '@/api/mavedb' +import {createAccessKey, createRoleAccessKey, deleteAccessKey, getMyCollections, searchMyExperiments, searchMyScoreSets} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import MvAccessKeyRow from '@/components/common/MvAccessKeyRow.vue' import MvOrcidLink from '@/components/common/MvOrcidLink.vue' import MvFloatField from '@/components/forms/MvFloatField.vue' diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 00000000..e258023a --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,65 @@ +import {describe, expect, it, vi} from 'vitest' + +import {describeRequestError, getErrorResponse} from './errors' + +vi.mock('axios', () => { + const isAxiosError = (error: unknown) => Boolean((error as {isAxiosError?: boolean})?.isAxiosError) + return {default: {isAxiosError}, isAxiosError} +}) + +function axiosError(data: unknown): unknown { + return {isAxiosError: true, response: {status: 400, data}, message: 'Request failed'} +} + +describe('describeRequestError', () => { + it('prefers a string detail from the server', () => { + expect(describeRequestError(axiosError({detail: 'Score set is private.'}))).toBe('Score set is private.') + }) + + it('serializes a non-string detail rather than losing it', () => { + const detail = [{loc: ['query', 'namespaces', 0], msg: 'Value error, must be one of ...'}] + + const message = describeRequestError(axiosError({detail})) + + expect(message).toContain('namespaces') + expect(message).toContain('Value error') + }) + + it('returns a plain string body as-is', () => { + expect(describeRequestError(axiosError('Gateway timeout'))).toBe('Gateway timeout') + }) + + it('serializes a response body with no detail field', () => { + expect(describeRequestError(axiosError({error: 'nope'}))).toBe('{"error":"nope"}') + }) + + it('falls back to the exception message for non-request failures', () => { + expect(describeRequestError(new Error('Network down'))).toBe('Network down') + }) + + it('falls back to the exception message when there is no response body', () => { + expect(describeRequestError({isAxiosError: true, message: 'Request failed'})).toBe('Unknown error.') + }) + + it('always returns something renderable', () => { + expect(describeRequestError(undefined)).toBe('Unknown error.') + expect(describeRequestError('a bare string')).toBe('Unknown error.') + }) +}) + +describe('getErrorResponse', () => { + it('extracts the status and body from an Axios error', () => { + expect(getErrorResponse(axiosError({detail: 'Score set is private.'}))).toEqual({ + status: 400, + data: {detail: 'Score set is private.'} + }) + }) + + it('falls back to a 500 for non-Axios errors', () => { + expect(getErrorResponse(new Error('Network down'))).toEqual({status: 500}) + }) + + it('falls back to a 500 when the Axios error has no response', () => { + expect(getErrorResponse({isAxiosError: true, message: 'Request failed'})).toEqual({status: 500}) + }) +}) diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..db0f50e1 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,43 @@ +import axios, {isAxiosError} from 'axios' + +/** + * Extract a message worth showing a user from a failed request. + * + * Prefers the server's own `detail`, which is where FastAPI puts the human-readable reason, and falls + * back through the raw response body to the exception message. Always returns something renderable, so + * callers can drop it straight into a toast. + * + * For the status code rather than the message, see `getErrorResponse` in `@/api/mavedb`. + */ +export function describeRequestError(error: unknown): string { + if (axios.isAxiosError(error) && error.response?.data) { + const data = error.response.data + if (typeof data === 'string') return data + if (typeof data === 'object' && data !== null && 'detail' in data) { + const detail = (data as {detail: unknown}).detail + // A string detail is the common case. A validation error is an array of objects; JSON is more use + // to a reader than "AxiosError: Request failed with status code 422". + return typeof detail === 'string' ? detail : JSON.stringify(detail) + } + return JSON.stringify(data) + } + return error instanceof Error ? error.message : 'Unknown error.' +} + +export interface ErrorResponse { + status: number + data?: Record +} + +/** + * Extract a normalized status and body from a caught Axios error. + * + * The counterpart to `describeRequestError`: use this when the caller branches on the status code, and + * that one when it just needs something to show the user. + */ +export function getErrorResponse(e: unknown): ErrorResponse { + if (isAxiosError(e) && e.response) { + return {status: e.response.status, data: e.response.data as Record} + } + return {status: 500} +} diff --git a/src/lib/genes.ts b/src/lib/genes.ts index 4b711a76..88d7d4aa 100644 --- a/src/lib/genes.ts +++ b/src/lib/genes.ts @@ -1,4 +1,5 @@ -import {getErrorResponse, type GeneResponse, type GeneScoreSet} from '@/api/mavedb' +import {type GeneResponse, type GeneScoreSet} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' export type GeneErrorState = 'not-found' | 'retryable' diff --git a/src/lib/orcid.ts b/src/lib/orcid.ts index 615ac72c..15405599 100644 --- a/src/lib/orcid.ts +++ b/src/lib/orcid.ts @@ -33,7 +33,7 @@ import {v4 as uuidv4} from 'uuid' import {computed, ref, Ref} from 'vue' import config from '../config' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' export interface OidcUserProfileBase { auth_time: number From 2d9b4e820c80789eaf3c9cb78e0a950d602bca33 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 6 Aug 2026 10:38:37 -0700 Subject: [PATCH 2/4] feat(ui): add a shared CSV column picker driven by namespace discovery Add use-csv-namespaces.ts and MvCsvColumnDialog.vue: a composable and dialog that fetch a score set's or variant's available CSV column namespaces from the new discovery endpoints and let a user pick which to include, replacing the score-set custom-download dialog's hand-picked checkbox list (scores/counts/mappedHgvs/etc.) that mapped to query params the API never actually read. Sections are grouped and labeled by the server (Measurements, Annotations, Clinical interpretation, Provenance) rather than hand-maintained client-side, and split by owning score set when a variant's calibrations span more than one. Defaults come from the API's selectedByDefault flag, so a research-use-only or rangeless calibration is offered but opts in explicitly. Wire it up in two places: - MvVariantPreview.vue's "Custom Data" dialog now uses it for score-set downloads instead of the old checkbox dialog - VariantScreen.vue gets a new "Download variant CSV" control backed by the variant-level CSV endpoint, split from the existing VA-Spec annotation downloads since they're a different kind of artifact (flat table vs. nested standard objects); use-variant-lookup.ts gains downloadVariantCsvFile and a shared downloadInProgressLabel Update openapi.d.ts for the new AvailableCsvNamespace / CsvNamespaceGroup schemas and endpoints, and switch score-sets.ts / variants.ts's CSV requests from drop_na_columns and include_post_mapped_hgvs to drop_unused_hgvs_columns and the namespace list, matching the API's new parameter names. --- src/api/mavedb/score-sets.ts | 39 +- src/api/mavedb/variants.ts | 22 + src/components/common/MvCsvColumnDialog.vue | 129 +++++ src/components/common/MvVariantPreview.vue | 59 +-- src/components/screens/VariantScreen.vue | 189 ++++--- src/composables/use-csv-namespaces.test.ts | 559 ++++++++++++++++++++ src/composables/use-csv-namespaces.ts | 220 ++++++++ src/composables/use-variant-lookup.ts | 51 +- src/schema/openapi.d.ts | 378 ++++++++++++- 9 files changed, 1511 insertions(+), 135 deletions(-) create mode 100644 src/components/common/MvCsvColumnDialog.vue create mode 100644 src/composables/use-csv-namespaces.test.ts create mode 100644 src/composables/use-csv-namespaces.ts diff --git a/src/api/mavedb/score-sets.ts b/src/api/mavedb/score-sets.ts index f2211a84..24ef5517 100644 --- a/src/api/mavedb/score-sets.ts +++ b/src/api/mavedb/score-sets.ts @@ -7,13 +7,10 @@ type ScoreSetSearch = components['schemas']['ScoreSetsSearch'] type ScoreSetsSearchResponse = components['schemas']['ScoreSetsSearchResponse'] export type ScoreSetsSearchFilterOptionsResponse = components['schemas']['ScoreSetsSearchFilterOptionsResponse'] -const HISTOGRAM_VARIANT_DATA_NAMESPACES = ['vep', 'scores', 'clingen'] +const HISTOGRAM_VARIANT_DATA_NAMESPACES = ['vep', 'scores', 'clingen', 'mavedb'] -function scoreSetVariantDataParams( - options: {includePostMappedHgvs?: boolean; namespaces?: string[]} = {} -): URLSearchParams { +function scoreSetVariantDataParams(options: {namespaces?: string[]} = {}): URLSearchParams { const params = new URLSearchParams() - if (options.includePostMappedHgvs) params.append('include_post_mapped_hgvs', 'true') for (const namespace of options.namespaces ?? []) params.append('namespaces', namespace) return params } @@ -25,10 +22,7 @@ function scoreSetVariantDataUrl(urn: string, params: URLSearchParams = new URLSe } export function histogramScoreSetVariantDataUrl(urn: string): string { - return scoreSetVariantDataUrl( - urn, - scoreSetVariantDataParams({includePostMappedHgvs: true, namespaces: HISTOGRAM_VARIANT_DATA_NAMESPACES}) - ) + return scoreSetVariantDataUrl(urn, scoreSetVariantDataParams({namespaces: HISTOGRAM_VARIANT_DATA_NAMESPACES})) } // --------------------------------------------------------------------------- @@ -103,17 +97,32 @@ export async function publishScoreSet(urn: string) { } export async function getScoreSetClinicalControlOptions(urn: string) { - const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/clinical-controls/options`) + const response = await axios.get( + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/clinical-controls/options` + ) return response.data } export async function downloadScoreSetFile(urn: string, type: 'scores' | 'counts'): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/${type}?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/${type}?drop_unused_hgvs_columns=true` ) return response.data } +/** + * Fetch the CSV column namespaces this score set has data for. + */ +export async function getScoreSetCsvNamespaces( + urn: string, + signal?: AbortSignal +): Promise { + const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/csv-namespaces`, { + signal + }) + return response.data +} + export async function downloadScoreSetVariantData(urn: string, params: URLSearchParams): Promise { const response = await axios.get(scoreSetVariantDataUrl(urn, params)) return response.data @@ -121,14 +130,14 @@ export async function downloadScoreSetVariantData(urn: string, params: URLSearch export async function getScoreSetScoresPreview(urn: string): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/scores?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/scores?drop_unused_hgvs_columns=true` ) return response.data } export async function getScoreSetCountsPreview(urn: string): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/counts?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/counts?drop_unused_hgvs_columns=true` ) return response.data } @@ -138,7 +147,9 @@ export async function downloadMappedVariants(urn: string) { return response.data } -export async function getRecentlyPublishedScoreSets(signal?: AbortSignal): Promise { +export async function getRecentlyPublishedScoreSets( + signal?: AbortSignal +): Promise { const response = await axios.get(`${config.apiBaseUrl}/score-sets/recently-published`, { headers: {accept: 'application/json'}, signal diff --git a/src/api/mavedb/variants.ts b/src/api/mavedb/variants.ts index f0db95a4..24f5a31c 100644 --- a/src/api/mavedb/variants.ts +++ b/src/api/mavedb/variants.ts @@ -8,6 +8,7 @@ type ScoreSet = components['schemas']['ScoreSet'] type VariantEffectMeasurementWithScoreSet = components['schemas']['VariantEffectMeasurementWithScoreSet'] type ClingenAlleleIdVariantLookupResponse = components['schemas']['ClingenAlleleIdVariantLookupResponse'] type MappedVariant = components['schemas']['MappedVariant'] +type AvailableCsvNamespace = components['schemas']['AvailableCsvNamespace'] export async function lookupVariantsByClingenId( clingenAlleleIds: string[] @@ -47,3 +48,24 @@ export async function getScoreSet(urn: string): Promise { const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}`) return response.data } + +/** + * Fetch the CSV column namespaces this variant has data for. + */ +export async function getVariantCsvNamespaces(urn: string, signal?: AbortSignal): Promise { + const response = await axios.get(`${config.apiBaseUrl}/variants/${encodeURIComponent(urn)}/csv-namespaces`, {signal}) + return response.data +} + +export function variantCsvUrl(urn: string, namespaces?: string[]): string { + const params = new URLSearchParams() + for (const namespace of namespaces ?? []) params.append('namespaces', namespace) + const query = params.toString() + const baseUrl = `${config.apiBaseUrl}/variants/${encodeURIComponent(urn)}/csv` + return query ? `${baseUrl}?${query}` : baseUrl +} + +export async function downloadVariantCsv(urn: string, namespaces?: string[]): Promise { + const response = await axios.get(variantCsvUrl(urn, namespaces)) + return response.data +} diff --git a/src/components/common/MvCsvColumnDialog.vue b/src/components/common/MvCsvColumnDialog.vue new file mode 100644 index 00000000..afc2e983 --- /dev/null +++ b/src/components/common/MvCsvColumnDialog.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/components/common/MvVariantPreview.vue b/src/components/common/MvVariantPreview.vue index 85fd7762..e0d2c4b0 100644 --- a/src/components/common/MvVariantPreview.vue +++ b/src/components/common/MvVariantPreview.vue @@ -10,28 +10,32 @@
+ @click="downloadFile('scores')" /> + @click="downloadFile('counts')" /> + @click="customDialogVisible = true" /> + + + Preparing {{ fileDownloadLabel }}… +
@@ -82,8 +86,7 @@
+ class="flex items-center justify-between border-b border-border-light border-t bg-bg px-4 py-2 tablet:px-5"> Counts Showing {{ countsRows.length }} of {{ countsData.length.toLocaleString() }} @@ -109,34 +112,26 @@
+ class="px-5 py-8 text-center text-sm italic text-text-muted"> No data available.
- - -
- -
- -
+ diff --git a/src/composables/use-csv-namespaces.test.ts b/src/composables/use-csv-namespaces.test.ts new file mode 100644 index 00000000..95261021 --- /dev/null +++ b/src/composables/use-csv-namespaces.test.ts @@ -0,0 +1,559 @@ +import {CanceledError} from 'axios' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref} from 'vue' + +import {useCsvNamespaces, type AvailableCsvNamespace, type CsvNamespaceSection} from './use-csv-namespaces' + +const getScoreSetCsvNamespaces = vi.fn() +const getVariantCsvNamespaces = vi.fn() + +vi.mock('@/api/mavedb', () => ({ + getScoreSetCsvNamespaces: (...args: unknown[]) => getScoreSetCsvNamespaces(...args), + getVariantCsvNamespaces: (...args: unknown[]) => getVariantCsvNamespaces(...args) +})) + +function entry( + overrides: Partial & { + selectedByDefault?: boolean + scoreSet?: {urn: string; title: string} + } +): AvailableCsvNamespace { + return { + namespace: 'scores', + label: 'Scores', + group: 'data', + ...overrides + } as AvailableCsvNamespace +} + +const SCORE_SET_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}), + entry({namespace: 'clinvar.2024_11', label: 'ClinVar significance (November 2024)', group: 'annotation'}), + entry({namespace: 'calibration.urn:mavedb:calibration-1', label: 'Brnich et al. 2019', group: 'calibration'}), + entry({namespace: 'score_set', label: 'Score set and publications', group: 'provenance'}) +] + +beforeEach(() => { + getScoreSetCsvNamespaces.mockReset() + getVariantCsvNamespaces.mockReset() +}) + +describe('useCsvNamespaces', () => { + it('does not fetch until load is called', () => { + useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('loads score set namespaces from the score set endpoint', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + expect(getVariantCsvNamespaces).not.toHaveBeenCalled() + expect(namespaces.value).toHaveLength(5) + }) + + it('loads variant namespaces from the variant endpoint', async () => { + getVariantCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + expect(getVariantCsvNamespaces).toHaveBeenCalledOnce() + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('groups namespaces into ordered sections, omitting empty ones', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data', 'annotation', 'calibration', 'provenance']) + expect(sections.value[1].namespaces.map((entry) => entry.namespace)).toEqual(['gnomad', 'clinvar.2024_11']) + expect(sections.value[2].title).toBe('Clinical interpretation') + }) + + it('omits sections with no namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue([entry({namespace: 'scores', group: 'data'})]) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data']) + }) + + it('serves labels from the API rather than deriving them', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibration = namespaces.value.find((n) => n.namespace.startsWith('calibration.')) + expect(calibration?.label).toBe('Brnich et al. 2019') + const clinvar = namespaces.value.find((n) => n.namespace.startsWith('clinvar.')) + expect(clinvar?.label).toBe('ClinVar significance (November 2024)') + }) + + it('caches per URN so reopening a dialog does not refetch', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + }) + + it('refetches when the URN changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + await load() + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + }) + + it('does not fetch without a URN', async () => { + const {load} = useCsvNamespaces({urn: ref(null), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('reports an error and offers nothing when the request fails', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new Error('boom')) + const {namespaces, hasNamespaces, error, loading, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(error.value).toBeTruthy() + expect(namespaces.value).toEqual([]) + expect(hasNamespaces.value).toBe(false) + expect(loading.value).toBe(false) + }) + + it('retries after a failure rather than caching the empty result', async () => { + getScoreSetCsvNamespaces.mockRejectedValueOnce(new Error('boom')).mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + expect(error.value).toBeNull() + expect(hasNamespaces.value).toBe(true) + }) + + it('ignores an aborted request instead of reporting it as an error', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new CanceledError('canceled')) + const {error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(error.value).toBeNull() + }) + + it('a superseded request neither clears the spinner nor drops the live request', async () => { + // The abort lands while the second request is still in flight. If the first one's cleanup ran + // unguarded, the dialog would stop showing a spinner and reset() could no longer abort what is + // actually loading. + let resolveSecond: (entries: AvailableCsvNamespace[]) => void = () => {} + getScoreSetCsvNamespaces + .mockRejectedValueOnce(new CanceledError('canceled')) + .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))) + + const urn = ref('urn:mavedb:00000001-a-1') + const {hasNamespaces, loading, load, reset} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + const first = load() + urn.value = 'urn:mavedb:00000001-a-2' + const second = load() + await first + + expect(loading.value).toBe(true) + + reset() + resolveSecond(SCORE_SET_ENTRIES) + await second + + // reset() aborted the live request, so its result must not land after the fact. + expect(hasNamespaces.value).toBe(false) + }) + + it('does not count a formatting extra as a column group', async () => { + // The Download button is enabled from this count, and a formatting flag produces no columns — that is + // how an empty `namespaces` request used to slip through and come back as the API's default. + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const extraOptions = ref([{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvs'}]) + const {selected, selectedExtras, selectedColumnGroups, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions + }) + + await load() + selected.value = [] + selectedExtras.value = ['dropUnusedHgvs'] + + expect(selectedColumnGroups.value).toBe(0) + }) + + it('reset clears loaded namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, load, reset} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + expect(hasNamespaces.value).toBe(true) + + reset() + + expect(hasNamespaces.value).toBe(false) + }) +}) + +describe('useCsvNamespaces selection', () => { + it('selects everything once the list loads', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toEqual(SCORE_SET_ENTRIES.map((entry) => entry.namespace)) + }) + + it('starts with nothing selected before loading', () => { + const {selected, allSelected} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll clears a full selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll restores everything from an empty selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + + toggleAll() + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('toggleAll selects everything from a partial selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('keeps a narrowed selection when the dialog is reopened for the same record', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + await load() + + expect(selected.value).toEqual(['scores']) + }) + + it('reselects everything when the record changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('clears the selection when loading fails', async () => { + getScoreSetCsvNamespaces.mockResolvedValueOnce(SCORE_SET_ENTRIES).mockRejectedValueOnce(new Error('boom')) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toEqual([]) + }) + + it('summarizes the selection so the picker states it outright', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + expect(selectionSummary.value).toBe('All columns selected') + + selected.value = ['scores', 'gnomad'] + expect(selectionSummary.value).toBe('2 of 5 column groups selected') + + selected.value = [] + expect(selectionSummary.value).toBe('No columns selected') + }) +}) + +describe('useCsvNamespaces default selection', () => { + const WITH_RESEARCH_USE_ONLY: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'Brnich et al. 2019', + group: 'calibration' + }), + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Research Use Only: Provisional', + group: 'calibration', + selectedByDefault: false + }) + ] + + it('leaves research-use-only calibrations unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(selected.value).toEqual(['scores', 'calibration.urn:mavedb:calibration-1']) + }) + + it('still offers them, so a user can opt in', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {namespaces, sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(namespaces.value).toHaveLength(3) + const calibrationSection = sections.value.find((section) => section.group === 'calibration') + expect(calibrationSection?.namespaces.map((entry) => entry.label)).toContain('Research Use Only: Provisional') + }) + + it('is not fully selected on load when a group is excluded by default', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {allSelected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(allSelected.value).toBe(false) + expect(selectionSummary.value).toBe('2 of 3 column groups selected') + }) + + it('select all reaches research-use-only groups, since that is an explicit act', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(allSelected.value).toBe(true) + expect(selected.value).toContain('calibration.urn:mavedb:calibration-2') + }) +}) + +describe('useCsvNamespaces extras', () => { + const NAMESPACE_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Score', group: 'data'}), + entry({namespace: 'scores_custom', label: 'Investigator-provided score columns', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD allele frequency', group: 'annotation'}) + ] + + const EXTRAS = [{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'}] + + function withExtras() { + return useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions: ref(EXTRAS) + }) + } + + it('counts only namespaces as column groups', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {totalColumnGroups, load} = withExtras() + + await load() + + // The investigator's score columns are a namespace now, so all three count; the flag does not. + expect(totalColumnGroups.value).toBe(3) + }) + + it('treats the investigator score columns as an ordinary namespace', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selected, load} = withExtras() + + await load() + + expect(selected.value).toContain('scores') + expect(selected.value).toContain('scores_custom') + }) + + it('is still "all selected" while a formatting extra is unchecked', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, allSelected, load} = withExtras() + await load() + + selectedExtras.value = [] + + expect(allSelected.value).toBe(true) + }) + + it('leaves formatting extras unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, load} = withExtras() + + await load() + + expect(selectedExtras.value).toEqual([]) + }) + + it('select all does not disturb formatting options', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, toggleAll, load} = withExtras() + await load() + selectedExtras.value = ['dropUnusedHgvsColumns'] + + toggleAll() + toggleAll() + + expect(selectedExtras.value).toEqual(['dropUnusedHgvsColumns']) + }) + + it('exposes formatting options for the Options section', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {formattingExtraOptions, load} = withExtras() + + await load() + + expect(formattingExtraOptions.value.map((o) => o.value)).toEqual(['dropUnusedHgvsColumns']) + }) +}) + +describe('useCsvNamespaces score set subdivision', () => { + const ONE_SCORE_SET: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'First Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-1', title: 'First Assay'} + }) + ] + + const TWO_SCORE_SETS: AvailableCsvNamespace[] = [ + ...ONE_SCORE_SET, + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Second Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-2', title: 'Second Assay'} + }) + ] + + function sectionFor(sections: CsvNamespaceSection[], group: string) { + return sections.find((section) => section.group === group) + } + + it('does not subdivide when only one score set is represented', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(ONE_SCORE_SET) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.subsections).toHaveLength(1) + expect(calibrations?.subsections[0].label).toBeNull() + }) + + it('subdivides by score set once more than one is represented', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + // Headed by title, not URN — but the URN comes along for disambiguation. + expect(calibrations?.subsections.map((s) => s.label)).toEqual(['First Assay', 'Second Assay']) + expect(calibrations?.subsections.map((s) => s.urn)).toEqual(['urn:mavedb:00000001-a-1', 'urn:mavedb:00000001-a-2']) + expect(calibrations?.subsections[0].namespaces.map((e) => e.label)).toEqual(['First Assay Calibration']) + expect(calibrations?.subsections[1].namespaces.map((e) => e.label)).toEqual(['Second Assay Calibration']) + }) + + it('never subdivides sections whose namespaces have no owning score set', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const data = sectionFor(sections.value, 'data') + expect(data?.subsections).toHaveLength(1) + expect(data?.subsections[0].label).toBeNull() + }) + + it('keeps the flat list alongside the subdivision', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.namespaces).toHaveLength(2) + expect(calibrations?.subsections.flatMap((s) => s.namespaces)).toHaveLength(2) + }) + + it('still selects everything by default across score sets', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {selected, allSelected, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1#1'), + kind: 'variant' + }) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toHaveLength(3) + }) +}) diff --git a/src/composables/use-csv-namespaces.ts b/src/composables/use-csv-namespaces.ts new file mode 100644 index 00000000..6ee757bf --- /dev/null +++ b/src/composables/use-csv-namespaces.ts @@ -0,0 +1,220 @@ +import axios from 'axios' +import {computed, ref, type Ref} from 'vue' + +import {getScoreSetCsvNamespaces, getVariantCsvNamespaces} from '@/api/mavedb' +import type {components} from '@/schema/openapi' + +export type AvailableCsvNamespace = components['schemas']['AvailableCsvNamespace'] +export type CsvNamespaceGroup = components['schemas']['CsvNamespaceGroup'] + +/** Section headings for a namespace picker, in the order they should be shown. */ +const GROUP_ORDER: CsvNamespaceGroup[] = ['data', 'annotation', 'calibration', 'provenance'] + +const GROUP_TITLES: Record = { + data: 'Measurements', + annotation: 'Annotations', + calibration: 'Clinical interpretation', + provenance: 'Provenance' +} + +export interface CsvNamespaceSubsection { + /** A heading for this run of namespaces, or null when the section needs no subdivision. */ + label: string | null + /** The URN behind the heading, for a tooltip or secondary text. Null when there is no heading. */ + urn: string | null + namespaces: AvailableCsvNamespace[] +} + +export interface CsvNamespaceSection { + group: CsvNamespaceGroup + title: string + /** Every namespace in the section, regardless of subdivision. */ + namespaces: AvailableCsvNamespace[] + /** + * The namespaces split by owning score set, when more than one is represented; otherwise a single + * null-labelled subsection that renders as an undivided list. A variant's calibrations can span score + * sets, and one means nothing against another's scores. + */ + subsections: CsvNamespaceSubsection[] +} + +/** + * Split a section's namespaces by owning score set, but only when more than one is represented. + * Headings use the title; the URN comes along for disambiguation, since titles can collide. + */ +function subdivideByScoreSet(namespaces: AvailableCsvNamespace[]): CsvNamespaceSubsection[] { + const owners = new Map() + for (const entry of namespaces) { + if (entry.scoreSet) owners.set(entry.scoreSet.urn, entry.scoreSet.title) + } + if (owners.size < 2) return [{label: null, urn: null, namespaces}] + + const subsections = [...owners.entries()] + .sort(([, titleA], [, titleB]) => titleA.localeCompare(titleB)) + .map(([urn, title]) => ({ + label: title, + urn, + namespaces: namespaces.filter((entry) => entry.scoreSet?.urn === urn) + })) + + // Anything without an owner still has to appear somewhere. + const unowned = namespaces.filter((entry) => !entry.scoreSet) + return unowned.length > 0 ? [...subsections, {label: null, urn: null, namespaces: unowned}] : subsections +} + +/** A query flag that is not a column group, rendered under "Options" and returned as an `extra`. */ +export interface CsvExtraOption { + label: string + value: string +} + +interface UseCsvNamespacesOptions { + /** The record whose namespaces to offer. Not watched; `load()` refetches when it sees a new URN. */ + urn: Ref + /** Which endpoint to ask. Variants widen over equivalent measurements; score sets do not. */ + kind: 'scoreSet' | 'variant' + /** Formatting flags. Which ones an endpoint accepts is the caller's business. */ + extraOptions?: Ref +} + +/** + * Load the CSV column namespaces a record has data for, ready to render as a picker. + * + * Fetched lazily — call `load()` when a dialog opens — and cached per URN. Labels and grouping come from + * the API, which alone knows calibration titles and release dates. + */ +export function useCsvNamespaces({urn, kind, extraOptions}: UseCsvNamespacesOptions) { + const namespaces = ref([]) + const loading = ref(false) + const error = ref(null) + const loadedUrn = ref(null) + const controller = ref(null) + const selected = ref([]) + const selectedExtras = ref([]) + + const formattingExtraOptions = computed(() => extraOptions?.value ?? []) + + const sections = computed(() => + GROUP_ORDER.map((group) => { + const inGroup = namespaces.value.filter((entry) => entry.group === group) + return { + group, + title: GROUP_TITLES[group], + namespaces: inGroup, + subsections: subdivideByScoreSet(inGroup) + } + }).filter((section) => section.namespaces.length > 0) + ) + + /** Whether the record has anything at all to offer. False while loading and on error. */ + const hasNamespaces = computed(() => namespaces.value.length > 0) + + /** Column groups on offer. Only namespaces count; checking an extra alone produces no file. */ + const totalColumnGroups = computed(() => namespaces.value.length) + + const selectedColumnGroups = computed(() => selected.value.length) + + const allSelected = computed( + () => totalColumnGroups.value > 0 && selectedColumnGroups.value === totalColumnGroups.value + ) + + /** Describes the current selection, so the picker never has to explain an implicit rule. */ + const selectionSummary = computed(() => { + if (selectedColumnGroups.value === 0) return 'No columns selected' + if (allSelected.value) return 'All columns selected' + return `${selectedColumnGroups.value} of ${totalColumnGroups.value} column groups selected` + }) + + /** Formatting options are left alone: "Select all" is about columns. */ + function selectAll(): void { + selected.value = namespaces.value.map((entry) => entry.namespace) + } + + /** + * Select the groups the API marks as defaults, and every refinement of them. Research-use-only and + * rangeless calibrations are offered but excluded, so checking one is a deliberate act. + */ + function selectDefaults(): void { + selected.value = namespaces.value + .filter((entry) => entry.selectedByDefault !== false) + .map((entry) => entry.namespace) + } + + /** Select everything, or clear when everything is already selected. */ + function toggleAll(): void { + if (!allSelected.value) { + selectAll() + return + } + selected.value = [] + } + + async function load(): Promise { + if (!urn.value) return + // Already have this record's list; a namespace set only changes when the record does. + if (loadedUrn.value === urn.value && namespaces.value.length > 0) return + + controller.value?.abort() + // Held locally as well: a superseded request must not clear the spinner or drop the live request's + // controller on its way out, or reset() would no longer be able to abort what is actually in flight. + const ownController = new AbortController() + controller.value = ownController + + loading.value = true + error.value = null + try { + const fetcher = kind === 'variant' ? getVariantCsvNamespaces : getScoreSetCsvNamespaces + const entries = await fetcher(urn.value, ownController.signal) + if (controller.value !== ownController) return + namespaces.value = entries + loadedUrn.value = urn.value + // Open on the common case and let the user narrow from there. Reopening for the same record + // short-circuits above, so a previous selection survives rather than being reset. + selectDefaults() + } catch (e: unknown) { + // An aborted request is a superseded one, not a failure to report. + if (axios.isCancel(e) || controller.value !== ownController) return + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = 'Could not load the available download options.' + } finally { + if (controller.value === ownController) { + loading.value = false + controller.value = null + } + } + } + + function reset(): void { + controller.value?.abort() + controller.value = null + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = null + loading.value = false + } + + return { + namespaces, + sections, + hasNamespaces, + loading, + error, + selected, + selectedExtras, + formattingExtraOptions, + totalColumnGroups, + selectedColumnGroups, + allSelected, + selectionSummary, + selectAll, + selectDefaults, + toggleAll, + load, + reset + } +} diff --git a/src/composables/use-variant-lookup.ts b/src/composables/use-variant-lookup.ts index f7b6bd57..5ffacf8f 100644 --- a/src/composables/use-variant-lookup.ts +++ b/src/composables/use-variant-lookup.ts @@ -1,7 +1,7 @@ -import axios from 'axios' import {computed, ref, shallowRef, watch, type ComputedRef, type Ref} from 'vue' import { + downloadVariantCsv, getVariantAnnotation, getVariantDetail, getHistogramVariantData, @@ -17,6 +17,7 @@ import { getPrimaryCalibration } from '@/lib/calibrations' import {triggerDownload} from '@/lib/downloads' +import {describeRequestError} from '@/lib/errors' import {getExperimentKeyword} from '@/lib/experiments' import {parseScoreSetVariantData, type Variant} from '@/lib/variants' import type {MeasurementType} from '@/lib/measurement-types' @@ -84,6 +85,9 @@ export interface UseVariantLookupReturn { // Downloads fetchVariantAnnotations: (annotationType: string) => Promise + downloadVariantCsvFile: (namespaces?: string[]) => Promise + /** What download is in flight, or null when idle. Indeterminate; see use-score-set-downloads. */ + downloadInProgressLabel: Ref } /** @@ -115,6 +119,7 @@ export function useVariantLookup( const variants = ref([]) const variantsStatus = ref<'NotLoaded' | 'Loading' | 'Loaded' | 'Error'>('NotLoaded') const selectedVariantUrn = ref(null) + const downloadInProgressLabel = ref(null) const showNucleotide = ref(true) const showProtein = ref(true) const showAssociatedNucleotide = ref(true) @@ -284,27 +289,45 @@ export function useVariantLookup( async function fetchVariantAnnotations(annotationType: string) { const activeVariant = selectedVariantDetail.value - if (!activeVariant?.urn) return + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + downloadInProgressLabel.value = 'annotations' try { const data = await getVariantAnnotation(activeVariant.urn, annotationType) triggerDownload(JSON.stringify(data), activeVariant.urn + '_' + annotationType + '.json', 'text/json') } catch (error: unknown) { - let serverMessage = '' - if (axios.isAxiosError(error) && error.response?.data) { - const data = error.response.data - if (typeof data === 'string') serverMessage = data - else if (typeof data === 'object' && data !== null && 'detail' in data) serverMessage = String(data.detail) - else serverMessage = JSON.stringify(data) - } else { - serverMessage = error instanceof Error ? error.message : 'Unknown error.' - } options?.toast?.add({ severity: 'error', summary: 'Download failed', - detail: `Could not fetch variant annotation: ${serverMessage}`, + detail: `Could not fetch variant annotation: ${describeRequestError(error)}`, + life: 4000 + }) + } finally { + downloadInProgressLabel.value = null + } + } + + /** + * Download the selected measurement's clinical CSV — the flat counterpart to + * `fetchVariantAnnotations`. Omitting `namespaces` asks the server for its default set. + */ + async function downloadVariantCsvFile(namespaces?: string[]) { + const activeVariant = selectedVariantDetail.value + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + + downloadInProgressLabel.value = 'variant CSV' + try { + const data = await downloadVariantCsv(activeVariant.urn, namespaces) + triggerDownload(data, `${activeVariant.urn}.csv`, 'text/csv') + } catch (error: unknown) { + options?.toast?.add({ + severity: 'error', + summary: 'Download failed', + detail: `Could not download the variant table: ${describeRequestError(error)}`, life: 4000 }) + } finally { + downloadInProgressLabel.value = null } } @@ -414,6 +437,8 @@ export function useVariantLookup( getKeyword, geneName, uniqueAssayCount, - fetchVariantAnnotations + fetchVariantAnnotations, + downloadVariantCsvFile, + downloadInProgressLabel } } diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts index d07f1dbc..a745ac0e 100644 --- a/src/schema/openapi.d.ts +++ b/src/schema/openapi.d.ts @@ -747,6 +747,32 @@ export interface paths { */ delete: operations["delete_score_set_api_v1_score_sets__urn__delete"]; }; + "/api/v1/score-sets/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get"]; + }; "/api/v1/score-sets/{urn}/variants/data": { /** * Get score set variant data in CSV format @@ -765,11 +791,19 @@ export interface paths { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -1328,6 +1362,62 @@ export interface paths { */ get: operations["get_variant_api_v1_variants__urn__get"]; }; + "/api/v1/variants/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get"]; + }; + "/api/v1/variants/{urn}/csv": { + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get: operations["get_variant_csv_data_api_v1_variants__urn__csv_get"]; + }; "/api/v1/alphafold-files/version": { /** * Proxy Alphafold Index @@ -1628,6 +1718,28 @@ export interface components { /** Version */ version: string; }; + /** + * AvailableCsvNamespace + * @description One CSV column namespace a record has data for, ready to be offered as a choice. + * + * Labels are served rather than derived client-side: only the server knows a calibration's title or a + * ClinVar release date. + */ + AvailableCsvNamespace: { + /** Recordtype */ + recordType?: string; + /** Namespace */ + namespace: string; + /** Label */ + label: string; + group: components["schemas"]["CsvNamespaceGroup"]; + scoreSet?: components["schemas"]["ShorterScoreSet"] | null; + /** + * Selectedbydefault + * @default true + */ + selectedByDefault?: boolean; + }; /** Body_create_score_calibration_route_api_v1_score_calibrations__post */ Body_create_score_calibration_route_api_v1_score_calibrations__post: { /** @@ -2391,6 +2503,12 @@ export interface components { */ copies: components["schemas"]["Range"] | number; }; + /** + * CsvNamespaceGroup + * @description Presentational grouping, so a client can section a namespace picker. + * @enum {string} + */ + CsvNamespaceGroup: "data" | "annotation" | "calibration" | "provenance"; /** * CurrentUser * @description User view model for information about the current user. @@ -5132,10 +5250,15 @@ export interface components { /** Uniprotidfrommappedmetadata */ uniprotIdFromMappedMetadata?: string | null; }; - /** ShorterScoreSet */ + /** + * ShorterScoreSet + * @description A score set's identity: enough to name it in a UI without rooting the display on its URN. + */ ShorterScoreSet: { /** Urn */ urn: string; + /** Title */ + title: string; /** Recordtype */ recordType?: string; }; @@ -10128,6 +10251,69 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Get score set variant data in CSV format * @description Return tabular variant data from a score set, identified by URN, in CSV format. @@ -10145,11 +10331,19 @@ export interface operations { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -10167,11 +10361,24 @@ export interface operations { start?: number; /** @description Maximum number of variants to return */ limit?: number; - /** @description One or more data types to include: "scores", "counts", "vep", "gnomad", "clingen", and/or ClinVar-versioned namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). */ + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ namespaces?: string[]; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; - include_custom_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `mavedb` namespace instead, e.g. `?namespaces=scores&namespaces=mavedb`. Passing true here is equivalent to appending that namespace. It will be removed in a future release. + */ include_post_mapped_hgvs?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `scores_custom` namespace instead. Passing true here is equivalent to appending that namespace, whose columns are emitted under the `scores` prefix as before. It will be removed in a future release. + */ + include_custom_columns?: boolean | null; }; header?: { "x-active-roles"?: string | null; @@ -10287,6 +10494,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -10348,6 +10560,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -12467,6 +12684,145 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get_variant_csv_data_api_v1_variants__urn__csv_get: { + parameters: { + query?: { + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ + namespaces?: string[] | null; + }; + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Variant data in CSV format, one row per measurement of the variant's allele. Columns cover identity, mapped coordinates, the measured score, external annotations, and each requested calibration's functional and ACMG interpretation. */ + 200: { + content: { + "application/json": unknown; + "text/csv": unknown; + }; + }; + /** @description Bad request. Check parameters and payload. */ + 400: { + content: never; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Proxy Alphafold Index * @description Proxy the AlphaFold files index (XML document). From 9277f0fd5d9108fd94bd99711e897ed9f7db14bb Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 6 Aug 2026 10:38:56 -0700 Subject: [PATCH 3/4] feat(ui): add a download indicator and fix annotation-stream memory use Fix streamAnnotationsInto (use-score-set-downloads.ts) accumulating NDJSON chunks as decoded strings: decoding to UTF-16, then joining, then building a Blob from the result meant a large pathogenicity-statement download held roughly five copies of the payload in memory at once and could run the tab out of it. Accumulate raw Uint8Array chunks instead and build the Blob directly from them, and count newline bytes rather than decoding to track progress. Also make a truncated stream (fewer lines than X-Total-Count) throw instead of silently saving a partial file, since the response has already started by the time that could be detected. Replace the per-feature annotatedDownloadInProgress/Progress state with a single fileDownloadLabel/fileDownloadProgress pair shared by every download this composable offers (scores, counts, mapped variants, custom data, and the VA-Spec streams), via a withIndicator() wrapper that also prevents two downloads running concurrently. Progress is indeterminate except for the VA-Spec streams, which can count records against X-Total-Count. Wire the shared indicator into ScoreSetDownloads.vue as one progress bar under the button row instead of one hung off the annotated-variants split button, disable every download button while any download is in flight, and add reportingFailure() so a rejected download surfaces a toast instead of an unhandled promise rejection in the console. Switch the custom-data dialog to MvCsvColumnDialog, matching MvVariantPreview and VariantScreen. --- .../score-set/ScoreSetDownloads.vue | 152 +++++++---- .../use-score-set-downloads.test.ts | 255 ++++++++++++++++++ src/composables/use-score-set-downloads.ts | 168 +++++++----- 3 files changed, 452 insertions(+), 123 deletions(-) create mode 100644 src/composables/use-score-set-downloads.test.ts diff --git a/src/components/score-set/ScoreSetDownloads.vue b/src/components/score-set/ScoreSetDownloads.vue index 79b98bf9..09a31cbd 100644 --- a/src/components/score-set/ScoreSetDownloads.vue +++ b/src/components/score-set/ScoreSetDownloads.vue @@ -2,57 +2,70 @@

Download files

- + + @click="reportingFailure('counts', () => downloadFile('counts'))" /> + @click="downloadMetadata" /> - -
- -
- -
-
+ @click="reportingFailure('mapped variants', downloadMappedVariantsFile)" /> + + + @click="customDialogVisible = true" /> +
+ + +
+ + + Preparing {{ fileDownloadLabel }}… + +
@@ -65,37 +78,24 @@
- - -
- -
- -
+ :extra-options="extraDownloadOptions" + header="Custom data download" + kind="scoreSet" + :urn="scoreSet.urn" + @confirm="handleCustomDownload" /> + + diff --git a/src/composables/use-score-set-downloads.test.ts b/src/composables/use-score-set-downloads.test.ts new file mode 100644 index 00000000..2c9639df --- /dev/null +++ b/src/composables/use-score-set-downloads.test.ts @@ -0,0 +1,255 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref, watch, type Ref} from 'vue' + +import {useScoreSetDownloads} from './use-score-set-downloads' + +const downloadScoreSetFile = vi.fn() +const downloadScoreSetVariantData = vi.fn() +const downloadMappedVariants = vi.fn() + +vi.mock('@/api/mavedb', () => ({ + downloadScoreSetFile: (...args: unknown[]) => downloadScoreSetFile(...args), + downloadScoreSetVariantData: (...args: unknown[]) => downloadScoreSetVariantData(...args), + downloadMappedVariants: (...args: unknown[]) => downloadMappedVariants(...args) +})) + +// The real one reaches for `document`; these tests run in the node environment. +vi.mock('@/lib/downloads', () => ({triggerDownload: vi.fn()})) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SCORE_SET = ref({urn: 'urn:mavedb:00000001-a-1'} as any) + +/** Collect every value the progress ref takes; it is back to null by the time a download resolves. */ +function recordProgress(source: Ref): {values: (number | null)[]; stop: () => void} { + const values: (number | null)[] = [] + const stop = watch(source, (value) => values.push(value), {flush: 'sync'}) + return {values, stop} +} + +beforeEach(() => { + downloadScoreSetFile.mockReset() + downloadScoreSetVariantData.mockReset() + downloadMappedVariants.mockReset() +}) + +describe('useScoreSetDownloads download indicator', () => { + it('is idle before anything is requested', () => { + const {fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('names the file being prepared while the request is in flight', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('counts') + expect(fileDownloadInProgress.value).toBe(true) + expect(fileDownloadLabel.value).toBe('Counts') + + release('accession,c_0\n') + await pending + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('leaves a CSV indeterminate, since a gzipped body has no measurable total', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + expect(fileDownloadProgress.value).toBeNull() + + release('accession,score\n') + await pending + }) + + it('clears the indicator when the request fails', async () => { + // Otherwise a failed 10MB download leaves the buttons disabled behind a bar that never stops. + downloadScoreSetFile.mockRejectedValueOnce(new Error('boom')) + const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloadFile('scores')).rejects.toThrow('boom') + + expect(fileDownloadInProgress.value).toBe(false) + }) + + it('ignores a second request while one is already running', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + await downloadFile('counts') + + // The first request still owns the indicator, and the second never reached the API. + expect(fileDownloadLabel.value).toBe('Scores') + expect(downloadScoreSetFile).toHaveBeenCalledTimes(1) + + release('accession,score\n') + await pending + }) + + it('covers the custom-columns download too', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetVariantData.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadMultipleData, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadMultipleData({namespaces: ['scores'], extras: []}) + expect(fileDownloadLabel.value).toBe('Custom data') + + release('accession,score\n') + await pending + + expect(fileDownloadLabel.value).toBeNull() + }) + + it('covers the mapped-variants download too', async () => { + let release: (data: unknown) => void = () => {} + downloadMappedVariants.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadMappedVariantsFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadMappedVariantsFile() + expect(fileDownloadLabel.value).toBe('Mapped variants') + + release([]) + await pending + + expect(fileDownloadLabel.value).toBeNull() + }) + + it('does nothing without a score set', async () => { + const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: ref(null)}) + + await downloadFile('scores') + + expect(downloadScoreSetFile).not.toHaveBeenCalled() + expect(fileDownloadInProgress.value).toBe(false) + }) +}) + +describe('useScoreSetDownloads annotation streaming shares the indicator', () => { + /** Serve an NDJSON body in the given chunks, with an optional X-Total-Count. */ + function mockStream(chunks: string[], totalCount: string | null = '4') { + const encoder = new TextEncoder() + let index = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: (name: string) => (name === 'X-Total-Count' ? totalCount : null)}, + body: { + getReader: () => ({ + read: async () => + index < chunks.length ? {done: false, value: encoder.encode(chunks[index++])} : {done: true} + }) + } + }) + ) + } + + beforeEach(() => { + // The stream saves its file through Blob + an anchor click rather than `triggerDownload`, so the + // node environment needs both stubbed for the completion path to run. + vi.stubGlobal('Blob', class {}) + vi.stubGlobal('URL', {createObjectURL: () => 'blob:stub', revokeObjectURL: () => {}}) + vi.stubGlobal('document', {createElement: () => ({click: () => {}})}) + }) + + it('reports a percentage, since a VA-Spec stream can count records', async () => { + mockStream(['{"a":1}\n', '{"a":2}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + const pending = downloads.streamVariantAnnotations('study-result', 'Functional Study Result') + expect(downloads.fileDownloadLabel.value).toBe('Functional Study Result') + await pending + stop() + + // One record then both, then released. + expect(values).toEqual([50, 100, null]) + }) + + it('refuses to save a truncated download', async () => { + // The bar stalling partway is the visible symptom of the server generator raising mid-stream: the 200 + // and its headers are long gone, so a short body is all the client ever sees. Saving it would hand + // the user a silently incomplete file. + mockStream(['{"a":1}\n'], '5') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloads.streamVariantAnnotations('functional-statement')).rejects.toThrow('received 1 of 5 records') + expect(downloads.fileDownloadInProgress.value).toBe(false) + }) + + it('counts records by byte, so a multi-byte character cannot skew the total', async () => { + // The old implementation decoded each chunk to a string first; retaining bytes is both exact and what + // keeps a large download out of the tab's memory. + mockStream(['{"p":"p.Trp26€"}\n', '{"p":"p.Met1?"}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values).toEqual([50, 100, null]) + }) + + it('does not overshoot 100% across chunk boundaries', async () => { + // `split('\n').length` counted one extra per chunk, so a record split across chunks was double + // counted and the old bar sailed past 100%. + mockStream(['{"a":1}\n{"a":', '2}\n{"a":3}\n{"a":4}\n'], '4') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(Math.max(...values.map((value) => value ?? 0))).toBe(100) + }) + + it('stays indeterminate when the total count header is absent', async () => { + // Previously this divided by zero and set the bar to Infinity. + mockStream(['{"a":1}\n'], null) + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values.every((value) => value === null)).toBe(true) + }) + + it('blocks a CSV download while a stream is running, since they share one indicator', async () => { + let releaseRead: () => void = () => {} + const encoder = new TextEncoder() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => '1'}, + body: { + getReader: () => ({ + read: () => + new Promise((resolve) => { + releaseRead = () => resolve({done: false, value: encoder.encode('{"a":1}\n')}) + }) + }) + } + }) + ) + const {streamVariantAnnotations, downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + void streamVariantAnnotations('study-result', 'Functional Study Result') + await Promise.resolve() + await Promise.resolve() + await downloadFile('scores') + + expect(fileDownloadLabel.value).toBe('Functional Study Result') + expect(downloadScoreSetFile).not.toHaveBeenCalled() + releaseRead() + }) +}) diff --git a/src/composables/use-score-set-downloads.ts b/src/composables/use-score-set-downloads.ts index 44385d67..c0431ab9 100644 --- a/src/composables/use-score-set-downloads.ts +++ b/src/composables/use-score-set-downloads.ts @@ -1,5 +1,7 @@ import {computed, ref, type Ref} from 'vue' +import type {CsvExtraOption} from '@/composables/use-csv-namespaces' + import {downloadScoreSetFile, downloadScoreSetVariantData, downloadMappedVariants} from '@/api/mavedb' import config from '@/config' import {triggerDownload} from '@/lib/downloads' @@ -11,55 +13,75 @@ export const TEXT_COLUMNS = ['hgvs_nt', 'hgvs_splice', 'hgvs_pro'] interface UseScoreSetDownloadsOptions { scoreSet: Ref - hasCounts?: Ref } -export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloadsOptions) { +export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { const customDialogVisible = ref(false) - const selectedDataOptions = ref([]) - const annotatedDownloadInProgress = ref(false) - const annotatedDownloadProgress = ref(0) const streamController = ref(null) - const dataTypeOptions = computed(() => { - const options = [ - {label: 'Scores', value: 'scores'}, - {label: 'Mapped HGVS', value: 'mappedHgvs'}, - {label: 'Custom columns', value: 'includeCustomColumns'}, - {label: 'Without NA columns', value: 'dropNaColumns'} - ] - if (hasCounts?.value) { - options.splice(1, 0, {label: 'Counts', value: 'counts'}) + /** What file is currently being prepared, or null when idle. One indicator for every download here. */ + const fileDownloadLabel = ref(null) + + /** + * Percent complete, or null when the download cannot report progress. + * + * Only the VA-Spec streams can: they carry `X-Total-Count` and emit one NDJSON record per line, so + * records can be tallied as they arrive. A CSV arrives as a single gzipped body whose `Content-Length` + * is the *compressed* size, which browsers compare against decompressed bytes received, so no usable + * percentage exists — and most of that wait is the server building the file before any byte is sent. + */ + const fileDownloadProgress = ref(null) + + const fileDownloadInProgress = computed(() => fileDownloadLabel.value !== null) + + /** Run *download* with the indicator showing, clearing it even if the request fails. */ + async function withIndicator(label: string, download: () => Promise): Promise { + if (fileDownloadLabel.value !== null) return + fileDownloadLabel.value = label + try { + return await download() + } finally { + fileDownloadLabel.value = null + fileDownloadProgress.value = null } - return options - }) + } + + /** Formatting flags. Column groups come from the csv-namespaces endpoint via MvCsvColumnDialog. */ + const extraDownloadOptions: CsvExtraOption[] = [ + {label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'} + ] async function downloadFile(type: 'scores' | 'counts') { if (!scoreSet.value) return - const data = await downloadScoreSetFile(scoreSet.value.urn, type) - triggerDownload(data, `${scoreSet.value.urn}_${type}.csv`) + await withIndicator(type === 'scores' ? 'Scores' : 'Counts', async () => { + const data = await downloadScoreSetFile(scoreSet.value!.urn, type) + triggerDownload(data, `${scoreSet.value!.urn}_${type}.csv`) + }) } - async function downloadMultipleData() { + /** + * Download the score set's variant data with the chosen column groups. Namespaces pass through + * verbatim — they are the API's own vocabulary. (This previously sent an unsupported `data_type` + * parameter, so every selection was silently ignored.) + */ + async function downloadMultipleData({namespaces, extras}: {namespaces: string[]; extras: string[]}) { if (!scoreSet.value) return const params = new URLSearchParams() - for (const opt of selectedDataOptions.value) { - if (opt === 'scores') params.append('data_type', 'scores') - else if (opt === 'counts') params.append('data_type', 'counts') - else if (opt === 'mappedHgvs') params.append('include_post_mapped_hgvs', 'true') - else if (opt === 'includeCustomColumns') params.append('include_custom_columns', 'true') - else if (opt === 'dropNaColumns') params.append('drop_na_columns', 'true') - } - if (!params.has('data_type')) params.append('data_type', 'scores') - const data = await downloadScoreSetVariantData(scoreSet.value.urn, params) - triggerDownload(data, `${scoreSet.value.urn}_custom.csv`) - customDialogVisible.value = false + for (const namespace of namespaces) params.append('namespaces', namespace) + if (extras.includes('dropUnusedHgvsColumns')) params.append('drop_unused_hgvs_columns', 'true') + await withIndicator('Custom data', async () => { + const data = await downloadScoreSetVariantData(scoreSet.value!.urn, params) + triggerDownload(data, `${scoreSet.value!.urn}_custom.csv`) + customDialogVisible.value = false + }) } async function downloadMappedVariantsFile() { if (!scoreSet.value) return - const data = await downloadMappedVariants(scoreSet.value.urn) - triggerDownload(JSON.stringify(data), `${scoreSet.value.urn}_mapped_variants.json`, 'text/json') + await withIndicator('Mapped variants', async () => { + const data = await downloadMappedVariants(scoreSet.value!.urn) + triggerDownload(JSON.stringify(data), `${scoreSet.value!.urn}_mapped_variants.json`, 'text/json') + }) } function downloadMetadata() { @@ -71,22 +93,25 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads function abortStream() { if (streamController.value) { streamController.value.abort() - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 + fileDownloadLabel.value = null + fileDownloadProgress.value = null } } - async function streamVariantAnnotations(annotationType: string) { - if (!scoreSet.value) return - abortStream() + async function streamVariantAnnotations(annotationType: string, label = 'annotations') { + const urn = scoreSet.value?.urn + if (!urn) return + await withIndicator(label, () => streamAnnotationsInto(urn, annotationType)) + } + + async function streamAnnotationsInto(urn: string, annotationType: string) { streamController.value = new AbortController() try { - annotatedDownloadInProgress.value = true - const response = await fetch( - `${config.apiBaseUrl}/score-sets/${scoreSet.value.urn}/annotated-variants/${annotationType}`, - {signal: streamController.value.signal, headers: {Accept: 'application/x-ndjson'}} - ) + const response = await fetch(`${config.apiBaseUrl}/score-sets/${urn}/annotated-variants/${annotationType}`, { + signal: streamController.value.signal, + headers: {Accept: 'application/x-ndjson'} + }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -96,27 +121,45 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads const reader = response.body?.getReader() if (!reader) throw new Error('Response body is not readable') - const decoder = new TextDecoder() - const chunks: string[] = [] + // Held as raw bytes rather than decoded strings. Accumulating strings and then joining them cost + // roughly five times the payload — JS strings are UTF-16, so the decoded chunks alone doubled it, + // the join doubled that again, and the Blob copied the result. A large pathogenicity download ran + // the tab out of memory. + const parts: Uint8Array[] = [] let processedCount = 0 while (true) { const {done, value} = await reader.read() - if (done) { - const blob = new Blob([chunks.join('')], {type: 'application/x-ndjson'}) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `${scoreSet.value!.urn}_annotated_variants_${annotationType}.ndjson` - anchor.click() - URL.revokeObjectURL(url) - break + if (done) break + + parts.push(value) + // NDJSON terminates every record with a newline, and 0x0A cannot appear inside a multi-byte UTF-8 + // sequence, so counting the byte is exact and needs no decoding or chunk-boundary bookkeeping. + for (const byte of value) { + if (byte === 0x0a) processedCount += 1 } - const chunk = decoder.decode(value) - chunks.push(chunk) - processedCount += chunk.split('\n').length - annotatedDownloadProgress.value = Math.round((processedCount / totalCount) * 100) + // Without a total there is nothing to divide by; stay indeterminate rather than report Infinity. + fileDownloadProgress.value = + totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : null } + + // The server generator yields one line per variant, so a short body means it stopped early — + // status and headers went out with the first chunk, so a truncated stream is the only symptom it + // can produce. Refuse to save a file that is quietly missing records. + if (totalCount > 0 && processedCount < totalCount) { + throw new Error( + `Download incomplete: received ${processedCount} of ${totalCount} records.` + + ' The server stopped sending partway through; check the API logs.' + ) + } + + const blob = new Blob(parts as BlobPart[], {type: 'application/x-ndjson'}) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `${urn}_annotated_variants_${annotationType}.ndjson` + anchor.click() + URL.revokeObjectURL(url) } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error' if (message !== 'The user aborted a request.') { @@ -124,20 +167,17 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads } } finally { streamController.value = null - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 } } return { // State customDialogVisible, - selectedDataOptions, - annotatedDownloadInProgress, - annotatedDownloadProgress, + fileDownloadInProgress, + fileDownloadLabel, + fileDownloadProgress, - // Computed - dataTypeOptions, + extraDownloadOptions, // Methods downloadFile, From 038957c80933a7f52d46dfb3afa91255639eb79e Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Wed, 12 Aug 2026 09:37:56 -0700 Subject: [PATCH 4/4] fix(score-set-downloads): label custom download button as CSV Also includes incidental reformatting. --- .../score-set/ScoreSetDownloads.vue | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/score-set/ScoreSetDownloads.vue b/src/components/score-set/ScoreSetDownloads.vue index 09a31cbd..612c827f 100644 --- a/src/components/score-set/ScoreSetDownloads.vue +++ b/src/components/score-set/ScoreSetDownloads.vue @@ -8,7 +8,8 @@ label="Scores" severity="secondary" size="small" - @click="reportingFailure('scores', () => downloadFile('scores'))" /> + @click="reportingFailure('scores', () => downloadFile('scores'))" + /> + @click="reportingFailure('counts', () => downloadFile('counts'))" + /> + @click="downloadMetadata" + /> + @click="reportingFailure('mapped variants', downloadMappedVariantsFile)" + /> + " + /> + @click="customDialogVisible = true" + />