diff --git a/data/STRchive-loci.schema.json b/data/STRchive-loci.schema.json index 833f805f..227c7eb6 100644 --- a/data/STRchive-loci.schema.json +++ b/data/STRchive-loci.schema.json @@ -7,7 +7,7 @@ "type": "object", "properties": { "id": { - "section": "Overview", + "section": "ID", "title": "ID", "description": "Unique identifier for the locus within STRchive in the form [disease_id]_[gene]. Additional characters may be added to the end of the ID to make it unique within STRchive, e.g. HFG_HOXA13-I, HFG_HOXA13-II.", "placeholder": "[disease_id]_[gene]", @@ -16,14 +16,14 @@ "pattern": "^[\\S]+_[\\S]+$" }, "disease_id": { - "section": "Overview", + "section": "ID", "title": "Disease ID", "description": "Disease abbreviation", "examples": ["CANVAS"], "type": ["string"] }, "gene": { - "section": "Overview", + "section": "ID", "title": "Gene", "description": "Gene symbol", "examples": ["RFC1"], diff --git a/data/STRtr-kit-evidence.json b/data/STRtr-kit-evidence.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/data/STRtr-kit-evidence.json @@ -0,0 +1 @@ +[] diff --git a/data/STRtr-kit-evidence.schema.json b/data/STRtr-kit-evidence.schema.json new file mode 100644 index 00000000..151a06ab --- /dev/null +++ b/data/STRtr-kit-evidence.schema.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "STRtr-kit-evidence.schema", + "title": "STRtr-kit Evidence", + "description": "Evidence to support pathogenicity of tandem repeat loci in human disease. Each entry describes evidence from a single publication or similar source. There will typically be multiple entries per locus, each describing evidence from different sources.", + "citation_format": "In free text strings: 'Some text [@doi:12345; @pmid:12345]'. In regular lists: ['doi:12345', 'pmid:12345']", + "type": "object", + "properties": { + "id": { + "section": "Evidence", + "title": "Locus", + "description": "Unique identifier for the locus within STRchive in the form [disease_id]_[gene]. Additional characters may be added to the end of the ID to make it unique within STRchive, e.g. HFG_HOXA13-I, HFG_HOXA13-II.", + "placeholder": "[disease_id]_[gene]", + "examples": ["CANVAS_RFC1", "HFG_HOXA13-I", "HFG_HOXA13-II"], + "type": ["string"], + "pattern": "^[\\S]+_[\\S]+$" + }, + + "citation": { + "section": "Evidence", + "title": "Citation", + "description": "Citation for the source of the evidence", + "examples": ["pmid:29507423"], + "type": ["string"], + "pattern": "^(?:doi|pmc|pmid|arxiv|isbn|url|mondo|omim|genereviews|malacard|orphanet|stripy|gnomad):.+$" + }, + + "genetic_evidence": { + "section": "Evidence", + "title": "Genetic Evidence", + "description": "One or more pieces of genetic evidence provided in the source", + "type": "array", + "uniqueItems": true, + "items": { + "title": "", + "type": "object", + "properties": { + "type": { + "title": "Type", + "description": "The type(s) of genetic evidence provided in the source", + "type": ["string", "null"], + "enum": [ + "probands", + "allele_effect", + "method", + "segregation", + "case_control" + ], + "enum_descriptions": { + "probands": "Unrelated Probands", + "allele_effect": "Relationship between allele size and/or motif sequence and phenotype e.g. age of onset and/or severity", + "method": "Method of predicting Pathogenicity", + "segregation": "Linkage Region for disease", + "case_control": "Case-control study" + } + }, + "notes": { + "title": "Notes", + "description": "Additional notes about the genetic evidence provided in the source including point upgrades/downgrades", + "examples": [ + "Segregation analysis in 3 affected families with LOD score of 3.5" + ], + "type": ["string", "null"], + "multiline": true + } + }, + "required": ["type"], + "allOf": [ + { + "if": { "properties": { "type": { "const": "probands" } } }, + "then": { + "properties": { + "probands": { + "title": "Probands", + "description": "Number of probands with the pathogenic expansion reported in the source", + "type": ["integer"], + "minimum": 0 + }, + "points": { + "title": "Points", + "description": "Points assigned to this locus based on the genetic evidence provided in the source, according to the STRtr-kit scoring system.", + "type": ["number"], + "minimum": 0, + "maximum": 6, + "default": 0.5, + "calc": "min(6, {probands} * 0.5)", + "scoring_range": "0–1.5 points per proband", + "upgrades": "+0.5 for inheritance/de novo evidence, +0.5 for functional evidence" + } + }, + "required": ["probands", "points"] + } + } + ] + } + }, + + "experimental_evidence": { + "section": "Evidence", + "title": "Experimental Evidence", + "description": "Experimental evidence supporting pathogenicity of the tandem repeat locus. Maximum overall score for all experimental evidence is 6.0 points.", + "type": "array", + "uniqueItems": true, + "category_max": 6.0, + "items": { + "type": "object", + "properties": { + "type": { + "title": "Type", + "description": "The type of experimental evidence provided in the source", + "type": ["string", "null"], + "enum": [ + "biochemical_function", + "protein_interaction", + "regulatory_impact", + "functional_alteration_patient_cells", + "functional_alteration_non_patient_cells", + "model_non_human", + "model_cell_culture", + "rescue_human_control", + "rescue_non_human_model", + "rescue_cell_culture", + "rescue_patient_cells" + ], + "enum_descriptions": { + "biochemical_function": "Biochemical function of the gene or repeat", + "protein_interaction": "Protein–protein interaction evidence", + "regulatory_impact": "Impact on regulation such as splicing, expression, or epigenetics", + "functional_alteration_patient_cells": "Functional alteration observed in patient-derived cells", + "functional_alteration_non_patient_cells": "Functional alteration observed in non-patient cells", + "model_non_human": "Evidence from non-human model organisms", + "model_cell_culture": "Evidence from cultured cell models", + "rescue_human_control": "Rescue experiments in healthy human cells", + "rescue_non_human_model": "Rescue experiments in non-human model organisms", + "rescue_cell_culture": "Rescue experiments in cultured cells", + "rescue_patient_cells": "Rescue experiments in patient-derived cells" + } + }, + + "notes": { + "title": "Notes", + "description": "Additional notes about the experimental evidence including point upgrades/downgrades", + "type": ["string", "null"], + "multiline": true + }, + + "points": { + "title": "Points", + "description": "Points assigned based on the experimental evidence provided in the source", + "type": ["number"], + "minimum": 0 + } + }, + "required": ["type", "points"], + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "biochemical_function", + "protein_interaction", + "regulatory_impact" + ] + } + } + }, + "then": { + "properties": { + "points": { + "default": 0.5, + "maximum": 2.0, + "scoring_range": "0–2", + "upgrades": "Multiple forms of evidence" + } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "functional_alteration_patient_cells" } + } + }, + "then": { + "properties": { + "points": { + "default": 1.0, + "maximum": 2.0, + "scoring_range": "0–2", + "upgrades": "Multiple patient cells studied" + } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "functional_alteration_non_patient_cells" } + } + }, + "then": { + "properties": { + "points": { + "default": 0.5, + "maximum": 1.0, + "scoring_range": "0–1", + "upgrades": "Multiple control cell types" + } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "model_non_human" } + } + }, + "then": { + "properties": { + "points": { + "default": 2.0, + "maximum": 4.0, + "scoring_range": "0–4", + "upgrades": "Multiple model organisms" + } + } + } + }, + { + "if": { + "properties": { + "type": { "const": "model_cell_culture" } + } + }, + "then": { + "properties": { + "points": { + "default": 1.0, + "maximum": 2.0, + "scoring_range": "0–2", + "upgrades": "Multiple cell types cultured" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "rescue_human_control", + "rescue_non_human_model" + ] + } + } + }, + "then": { + "properties": { + "points": { + "default": 2.0, + "maximum": 4.0, + "scoring_range": "0–4", + "upgrades": "Multiple rescue systems" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "rescue_cell_culture", + "rescue_patient_cells" + ] + } + } + }, + "then": { + "properties": { + "points": { + "default": 1.0, + "maximum": 2.0, + "scoring_range": "0–2", + "upgrades": "Multiple rescue approaches" + } + } + } + } + ] + } + } + }, + + "required": ["id", "citation"] +} diff --git a/site/bun.lock b/site/bun.lock index 8b0d5a93..0ac05398 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -15,6 +15,7 @@ "astro-google-analytics": "^1.0.3", "clsx": "^2.1.1", "echarts": "^6.0.0", + "expr-eval": "^2.0.2", "json-schema-library": "^10.2.1", "lodash-es": "^4.17.21", "plotly.js-dist": "^3.1.0", @@ -582,6 +583,8 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "expr-eval": ["expr-eval@2.0.2", "", {}, "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], diff --git a/site/package.json b/site/package.json index 1214df65..b031cded 100755 --- a/site/package.json +++ b/site/package.json @@ -22,6 +22,7 @@ "astro-google-analytics": "^1.0.3", "clsx": "^2.1.1", "echarts": "^6.0.0", + "expr-eval": "^2.0.2", "json-schema-library": "^10.2.1", "lodash-es": "^4.17.21", "plotly.js-dist": "^3.1.0", diff --git a/site/src/components/SchemaForm.jsx b/site/src/components/SchemaForm.jsx index 93a507e6..4498e094 100644 --- a/site/src/components/SchemaForm.jsx +++ b/site/src/components/SchemaForm.jsx @@ -1,5 +1,12 @@ -import { cloneElement, Fragment, useMemo } from "react"; +import { + cloneElement, + createContext, + Fragment, + useContext, + useMemo, +} from "react"; import { FaArrowDown, FaArrowUp, FaPlus, FaTrash } from "react-icons/fa6"; +import { Parser } from "expr-eval"; import { compileSchema, draft2020, extendDraft } from "json-schema-library"; import { cloneDeep, @@ -18,24 +25,31 @@ import NumberBox from "@/components/NumberBox"; import Select from "@/components/Select"; import TextBox from "@/components/TextBox"; import { makeList } from "@/util/format"; +import { usePrevious } from "@/util/hooks"; +import { sleep } from "@/util/misc"; import classes from "./SchemaForm.module.css"; +/** calc expression parser */ +const parser = new Parser(); + +const FormContext = createContext({}); + /** form automatically generated from json schema */ const SchemaForm = ({ schema, sections, data, onChange, children }) => { /** compile schema */ - const rootNode = useMemo( + const root = useMemo( () => compileSchema(schema, { drafts: [draft] }), [schema], ); /** if no initial data, generate blank values */ - data ??= rootNode.getData(null, { extendDefaults: false }); + data ??= root.getData(null, { extendDefaults: false }); /** revalidate when data changes */ const { errors } = useMemo(() => { - rootNode.context.data = data; - return rootNode.validate(data); - }, [rootNode, data]); + root.context.data = data; + return root.validate(data); + }, [root, data]); return ( <> @@ -45,15 +59,11 @@ const SchemaForm = ({ schema, sections, data, onChange, children }) => {
{section} - + + +
))} @@ -74,6 +84,7 @@ const lessThan = { const otherValue = get(fullData, otherKey); if (thisValue === null || otherValue === null) return; if (thisValue <= otherValue) return; + return node.createError("compare-value-error", { pointer, value: otherValue, @@ -115,21 +126,15 @@ const draft = extendDraft(draft2020, { * @param {import("json-schema-library").SchemaNode} props.node * @param {import("json-schema-library").JsonError[]} props.errors */ -const Field = ({ - rootNode, - schema, - section, - node, - path = "", - data, - setData, - errors, -}) => { +const Field = ({ node, path = "" }) => { + /** top level form state */ + const form = useContext(FormContext); + /** are we at top level of schema */ const level = split(path).length; /** filter out fields that should not be displayed in this section */ - if (level === 1 && node.schema.section !== section) return; + if (level === 1 && node.schema.section !== form.section) return; /** schema props */ const { @@ -147,6 +152,8 @@ const Field = ({ hide, multiline, combobox, + allOf, + calc, } = node.schema; /** explicitly hide field */ @@ -159,15 +166,72 @@ const Field = ({ const name = path; /** get nested data value from path */ - const value = get(data, path); + const value = get(form.data, path); /** set nested data value from path */ const onChange = (value) => { - let _data = cloneDeep(data); + let _data = cloneDeep(form.data); _data = set(_data, path, value); - setData(_data); + form.onChange(_data); }; + /** handle calc expression */ + let calcValue; + if (calc) { + let expression = calc; + + let variables = uniq( + /** find all pairs of braces e.g. {VAR} */ + [...expression.matchAll(/(\{.*?\})/g)].map((match) => match[1]), + ) + .map((path, index) => { + /** assign unique single character variable */ + const char = String.fromCharCode(97 + index); + /** replace with char */ + expression = expression.replaceAll(path, char); + /** get path value between braces */ + path = path.slice(1, -1); + return { path, char }; + }) + .map((variable) => { + const fullPath = join( + /** path of current field */ + path, + /** go up one level so e.g. {some_field} is relative to current */ + "..", + /** relative path from var */ + variable.path, + ); + /** get variable value from form data */ + try { + const { char } = variable; + const value = get(form.data, fullPath); + return [char, value]; + } catch {} + }) + /** remove any unresolved paths */ + .filter(Boolean); + + /** make map of var letter to value */ + variables = Object.fromEntries(variables); + + /** evaluate expression */ + try { + calcValue = parser.evaluate(expression, variables); + } catch {} + } + + /** keep track of previous calc value */ + const prevCalcValue = usePrevious(calcValue); + + /** if "calc" value changed, update field value */ + if ( + prevCalcValue !== undefined && + calcValue !== undefined && + calcValue !== prevCalcValue + ) + sleep().then(() => onChange(calcValue)); + /** help tooltip to show */ const tooltip = [ description, @@ -187,7 +251,8 @@ const Field = ({ /** is the field required to be set */ const required = - schema.required?.includes(split(name).pop()) && !types.includes("null"); + form.schema.required?.includes(split(name).pop()) && + !types.includes("null"); /** full label elements to show */ const label = ( @@ -198,7 +263,7 @@ const Field = ({ ); /** get validation errors associated with this field */ - const fieldErrors = errors.filter( + const fieldErrors = form.errors.filter( ({ data }) => data.pointer === path.replace(/^#?\/?/, "#/"), ); /** is there an error on this field */ @@ -271,31 +336,44 @@ const Field = ({ else el?.setCustomValidity(""); }; - if (types.includes("object")) + if (types.includes("object")) { + /** regular child properties */ + const regular = Object.keys(node.schema.properties ?? {}).map((key) => [ + key, + /** child node */ + node.getChildSelection(key)[0], + ]); + + /** conditional child properties */ + const conditional = Object.entries( + allOf + /** are all conditions satisfied */ + ?.filter((conditional) => + Object.entries(conditional.if.properties).every( + ([key, value]) => get(form.data, join(path, key)) === value.const, + ), + ) + /** get "then" properties schema */ + .map((conditional) => conditional.then.properties) + .reduce((acc, props) => ({ ...acc, ...props }), {}) ?? {}, + ).map(([key, node]) => [ + key, + /** child node */ + compileSchema(node), + ]); + /** object group */ control = (
{level > 0 &&
{label}
} - {Object.keys(node.schema.properties).map((key) => { - return ( - - ); + {regular.concat(conditional).map(([key, node]) => { + return ; })}
); - else if (types.includes("array")) { + } else if (types.includes("array")) { /** array group */ - const items = get(data, path)?.length ?? 0; + const items = get(form.data, path)?.length ?? 0; control = (
@@ -304,27 +382,22 @@ const Field = ({
+
+ )} + + + submit(data)} + /> + +
+ Summary + +
+
Genetic Points
+
{geneticPoints}
+ +
Experimental Points
+
{experimentalPoints}
+ +
Total
+
{totalPoints}
+
+ + + {status === "" && ( + <> + This will make a public pull request on{" "} + our GitHub. You'll get a link once it's + created. + + )} + {startCase(status)}{" "} + {response && ( + {shortenUrl(response.link)} + )} + + + +
+ + ); +}; + +export default EvidenceForm; diff --git a/site/src/pages/loci/[id].astro b/site/src/pages/loci/[id].astro index 84c76c8e..d621059c 100644 --- a/site/src/pages/loci/[id].astro +++ b/site/src/pages/loci/[id].astro @@ -4,7 +4,7 @@ import Layout from "@/layouts/Layout.astro"; import { FaCircleMinus, FaCirclePlus, FaRegCalendar } from "react-icons/fa6"; import { LiaBarcodeSolid, LiaSlashSolid } from "react-icons/lia"; -import { LuFeather } from "react-icons/lu"; +import { LuBookCheck, LuFeather } from "react-icons/lu"; import { TbDna2, TbVirus } from "react-icons/tb"; import Button from "@/components/Button"; import Heading from "@/components/Heading"; @@ -137,10 +137,25 @@ const blame = getJsonBlame( }
- +
+ + + +
diff --git a/site/src/pages/loci/[id]/evidence.astro b/site/src/pages/loci/[id]/evidence.astro new file mode 100644 index 00000000..aa3c52d7 --- /dev/null +++ b/site/src/pages/loci/[id]/evidence.astro @@ -0,0 +1,28 @@ +--- +/** EDIT LOCUS PAGE */ + +import Layout from "@/layouts/Layout.astro"; +import TableOfContents from "@/components/TableOfContents"; +import EvidenceForm from "@/locus/EvidenceForm"; +import loci from "~/STRchive-loci.json"; + +/** generate pages for each datum, paths based on id */ +export const getStaticPaths = async () => + loci.map(({ id }) => ({ params: { id } })); + +/** current page loci id in url */ +const { id } = Astro.params; + +/** look up full locus entry from id */ +const locus = loci.find((locus) => locus.id === id) || {}; +--- + + + + + + diff --git a/site/src/util/hooks.js b/site/src/util/hooks.js index d94f39ff..b0bc8dc8 100644 --- a/site/src/util/hooks.js +++ b/site/src/util/hooks.js @@ -43,3 +43,11 @@ export const useQuery = ( reset, }; }; + +/** use value from previous render */ +export const usePrevious = (value) => { + const ref = useRef(); + const prev = ref.current; + ref.current = value; + return prev; +}