diff --git a/api/routes/web.py b/api/routes/web.py index 3ee75dad5..817174510 100644 --- a/api/routes/web.py +++ b/api/routes/web.py @@ -11,7 +11,6 @@ from fastapi import APIRouter from fastapi.responses import StreamingResponse -from pydantic import BaseModel from api.utils.db import ( Connection, @@ -26,6 +25,7 @@ from db.python.tables.participant import ParticipantFilter from db.python.tables.project import ProjectPermissionsTable from db.python.utils import GenericFilter, GenericMetaFilter +from models.base import SMBase from models.enums.web import SeqrDatasetType from models.models.participant import NestedParticipant from models.models.project import ProjectId @@ -35,7 +35,7 @@ from models.utils.sequencing_group_id_format import sequencing_group_id_transform_to_raw -class SearchResponseModel(BaseModel): +class SearchResponseModel(SMBase): """Parent model class, allows flexibility later on""" responses: list[SearchResponse] @@ -60,10 +60,10 @@ async def get_project_summary( return summary.to_external() -class ProjectParticipantGridFilter(BaseModel): +class ProjectParticipantGridFilter(SMBase): """filters for participant grid""" - class ParticipantGridParticipantFilter(BaseModel): + class ParticipantGridParticipantFilter(SMBase): """participant filter option for participant grid""" id: GenericFilter[int] | None = None @@ -73,14 +73,14 @@ class ParticipantGridParticipantFilter(BaseModel): reported_gender: GenericFilter[str] | None = None karyotype: GenericFilter[str] | None = None - class ParticipantGridFamilyFilter(BaseModel): + class ParticipantGridFamilyFilter(SMBase): """family filter option for participant grid""" id: GenericFilter[int] | None = None external_id: GenericFilter[str] | None = None meta: GenericMetaFilter | None = None - class ParticipantGridSampleFilter(BaseModel): + class ParticipantGridSampleFilter(SMBase): """sample filter option for participant grid""" id: GenericFilter[str] | None = None @@ -88,7 +88,7 @@ class ParticipantGridSampleFilter(BaseModel): external_id: GenericFilter[str] | None = None meta: GenericMetaFilter | None = None - class ParticipantGridSequencingGroupFilter(BaseModel): + class ParticipantGridSequencingGroupFilter(SMBase): """sequencing group filter option for participant grid""" id: GenericFilter[str] | None = None @@ -98,7 +98,7 @@ class ParticipantGridSequencingGroupFilter(BaseModel): technology: GenericFilter[str] | None = None platform: GenericFilter[str] | None = None - class ParticipantGridAssayFilter(BaseModel): + class ParticipantGridAssayFilter(SMBase): """assay filter option for participant grid""" id: GenericFilter[int] | None = None @@ -211,7 +211,7 @@ async def get_project_summary_with_limit( ) -class ExportProjectParticipantFields(BaseModel): +class ExportProjectParticipantFields(SMBase): """fields for exporting project participants""" family_keys: list[str] diff --git a/web/src/index.css b/web/src/index.css index 892558bc9..e30475a83 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -12,9 +12,11 @@ --color-bg-card: #fff; --color-bg-disabled-card: #eeeeee; --color-table-header: #eaeaea; + --color-text-red: #ff0000; --color-border-color: #c8c9ca; --color-border-default: #d0d7de; + --color-border-red: #f04848; --color-divider: rgba(0, 0, 0, 0.3); @@ -43,11 +45,13 @@ html[data-theme='dark-mode'] { --color-border-color: #3a3a3a; --color-border-default: #292a2b; + --color-border-red: #921111; --color-divider: rgba(255, 255, 255, 0.2); --color-text-primary: rgba(255, 255, 255, 0.87); --color-text-medium: rgba(255, 255, 255, 0.6); --color-text-disabled: rgba(255, 255, 255, 0.38); + --color-text-red: #fb6f6f; --color-check-green: #659251; --color-table-header: rgba(0, 0, 0, 0.15); @@ -150,6 +154,23 @@ blockquote { width: 100% !important; } +.ui.accordion .title:not(.ui) { + color: var(--color-text-primary); +} + +.ui .card { + background: var(--color-bg-card); + color: var(--color-text-primary); + border: 1px solid var(--color-border-default); +} + +textarea { + background: var(--color-bg); + color: var(--color-text-primary); + border: 1px solid var(--color-border-default); + +} + /* https://alexandergottlieb.com/2018/02/22/overflow-scroll-and-the-right-padding-problem-a-css-only-solution/ */ .projectSummaryGrid::after { content: ''; diff --git a/web/src/pages/project/ExportProjectButton.tsx b/web/src/pages/project/ExportProjectButton.tsx index ff14094d8..e2876a39b 100644 --- a/web/src/pages/project/ExportProjectButton.tsx +++ b/web/src/pages/project/ExportProjectButton.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Button, Message } from 'semantic-ui-react' +import { Button, ButtonGroup, Dropdown, Message } from 'semantic-ui-react' import _ from 'lodash' import { @@ -10,6 +10,19 @@ import { } from '../../sm-api/api' import { MetaSearchEntityPrefix, ProjectGridHeaderGroup } from './ProjectColumnOptions' +const extensionFromExportType = (exportType: ExportType) => { + switch (exportType) { + case ExportType.Csv: + return 'csv' + case ExportType.Tsv: + return 'tsv' + case ExportType.Json: + return 'json' + default: + return 'txt' + } +} + export const ProjectExportButton: React.FunctionComponent<{ participants_in_query: number projectName: string @@ -17,11 +30,13 @@ export const ProjectExportButton: React.FunctionComponent<{ filterValues: ProjectParticipantGridFilter headerGroups: ProjectGridHeaderGroup[] }> = ({ filterValues, projectName, headerGroups, participants_in_query }) => { + const [exportType, setExportType] = React.useState(ExportType.Csv) const [isDownloading, setIsDownloading] = React.useState(false) const [downloadError, setDownloadError] = React.useState(undefined) // const keys = Object.keys(headerGroups).reduce((prev, k) => ({...prev, k}), {}) - const download = () => { + const download = (_exportType: ExportType) => { + if (!_exportType) return setIsDownloading(true) setDownloadError(undefined) @@ -45,16 +60,21 @@ export const ProjectExportButton: React.FunctionComponent<{ .map((f) => f.name), } new WebApi() - .exportProjectParticipants(ExportType.Csv, projectName, { + .exportProjectParticipants(_exportType, projectName, { query: filterValues, fields: fields, }) .then((resp) => { setIsDownloading(false) - const url = window.URL.createObjectURL(new Blob([resp.data])) + let data = resp.data + if (_exportType == ExportType.Json) { + data = JSON.stringify(data) + } + const url = window.URL.createObjectURL(new Blob([data])) const link = document.createElement('a') link.href = url - const defaultFilename = `project-export-${projectName}.csv` + const ext = extensionFromExportType(_exportType) + const defaultFilename = `project-export-${projectName}.${ext}` link.setAttribute( 'download', resp.headers['content-disposition']?.split('=')?.[1] || defaultFilename @@ -64,11 +84,45 @@ export const ProjectExportButton: React.FunctionComponent<{ }) .catch((er) => setDownloadError(er.message)) } + + // onClick={() => download(ExportType.Csv) + const exportOptions = [ + { + key: 'csv', + text: 'CSV', + value: ExportType.Csv, + }, + { + key: 'tsv', + text: 'TSV', + value: ExportType.Tsv, + }, + { + key: 'json', + text: 'JSON (all columns)', + value: ExportType.Json, + }, + ] + return ( <> - + + + { + debugger + setExportType(data.value as ExportType) + download(data.value as ExportType) + }} + options={exportOptions} + /> + {downloadError && (
diff --git a/web/src/pages/project/JsonEditor.tsx b/web/src/pages/project/JsonEditor.tsx new file mode 100644 index 000000000..0b127b593 --- /dev/null +++ b/web/src/pages/project/JsonEditor.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import { Button } from 'semantic-ui-react' + +interface JsonEditorProps { + jsonStr?: string + jsonObj?: any + onChange: (json: object) => void +} + +export const JsonEditor: React.FunctionComponent = ({ + jsonStr, + jsonObj, + onChange, +}) => { + const [innerJsonValue, setInnerJsonValue] = React.useState( + jsonObj ? JSON.stringify(jsonObj, null, 2) : jsonStr || '' + ) + const [error, setError] = React.useState(undefined) + + React.useEffect(() => { + if (jsonObj) { + setInnerJsonValue(JSON.stringify(jsonObj, null, 2)) + return + } + if (jsonStr) { + setInnerJsonValue(jsonStr) + } + }, [jsonStr, jsonObj]) + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInnerJsonValue(value) + try { + const newJson = JSON.parse(value) + setError(undefined) + } catch (e: any) { + setError(e.message) + } + } + + const submit = () => { + try { + const newJson = JSON.parse(innerJsonValue) + onChange(newJson) + } catch (e: any) { + setError(e.message) + } + } + + return ( +
+