diff --git a/app/builder/Form.client.tsx b/app/builder/Form.client.tsx index ae8dd7d2..8f3dd5a4 100644 --- a/app/builder/Form.client.tsx +++ b/app/builder/Form.client.tsx @@ -34,6 +34,43 @@ const App = () => { } }, [archive, activetCatalog]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + // on builder page check for zip in browser storage and loads it + if (activetCatalog.title === "") { + return; + } + if (typeof indexedDB === "undefined") { + console.error( + "IndexedDB not supported, unable to save workflow.zip file." + ); + return; + } + const open = indexedDB.open("haddock3", 1); + open.onsuccess = function () { + const db = open.result; + const tx = db.transaction("zips", "readwrite"); + const zips = tx.objectStore("zips"); + const request = zips.get("workflow.zip"); + request.onsuccess = function () { + const zip: Blob = request.result; + console.log("zip", zip); + if (zip === undefined) { + return; + } + const url = URL.createObjectURL(zip); + loadWorkflowArchive(url) + .finally(() => { + URL.revokeObjectURL(url); + }) + .catch((error) => { + console.error("Error loading workflow from indexeddb", error); + }); + // remove zip from indexeddb so next visit to builder page loads nothing + zips.delete("workflow.zip"); + }; + }; + }, [activetCatalog]); // eslint-disable-line react-hooks/exhaustive-deps + return (
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index ded43019..169be816 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -82,6 +82,7 @@ export const Navbar = () => { Builder + Scenario Upload Manage About diff --git a/app/components/ui/dialog.tsx b/app/components/ui/dialog.tsx new file mode 100644 index 00000000..38819f51 --- /dev/null +++ b/app/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx index 89461cd5..dd864840 100644 --- a/app/components/ui/input.tsx +++ b/app/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef( + + +

{title}

+
+ {description} + + + ); +} + +export default function ScenariosIndex() { + return ( +
+

Scenarios

+
+ {scenarios.map((scenario) => ( + + ))} +
+
+ ); +} diff --git a/app/routes/scenarios.antibody-antigen.tsx b/app/routes/scenarios.antibody-antigen.tsx new file mode 100644 index 00000000..cb7dd54f --- /dev/null +++ b/app/routes/scenarios.antibody-antigen.tsx @@ -0,0 +1,218 @@ +import { useActionData, useNavigate, useSubmit } from "@remix-run/react"; +import JSZip from "jszip"; +import { Output, instance, object } from "valibot"; +import { WORKFLOW_CONFIG_FILENAME } from "~/bartender-client/constants"; + +import { Input } from "~/components/ui/input"; +import { action as uploadaction } from "./upload"; +import { FormItem } from "../scenarios/FormItem"; +import { FormDescription } from "../scenarios/FormDescription"; +import { PDBFileInput } from "../scenarios/PDBFileInput"; +import { ActionButtons, handleActionButton } from "~/scenarios/actions"; +import { parseFormData } from "~/scenarios/schema"; + +export const action = uploadaction; + +const Schema = object({ + antibody: instance(File, "Antibody structure as PDB file", []), + antigen: instance(File, "Antibody structure as PDB file", []), + // restraints get type==='' so cannot check for file type + ambig_fname: instance(File, "Ambiguous restraints as TBL file"), + unambig_fname: instance(File, "Unambiguous restraints as TBL file"), + reference_fname: instance(File, "Reference structure as PDB file", []), +}); +type Schema = Output; + +function generateWorkflow(data: Schema) { + // create workflow.cfg with form data as values for filename fields + + // Workflow based on + // scenario2a-NMR-epitope-pass-short.cfg + // in https://surfdrive.surf.nl/files/index.php/s/HvXxgxCTY1DiPsV + // from + // https://www.bonvinlab.org/education/HADDOCK3/HADDOCK3-antibody-antigen/#setuprequirements + // but made valid for easy expertise level + return ` +# ==================================================================== +# Antibody-antigen docking example with restraints from the antibody +# paratope to the NMR-identified epitope on the antigen (as passive) +# ==================================================================== + +# directory name of the run +run_dir = "scenario2a-NMR-epitope-pass-short" + +# Compute mode +mode = "local" +# 10 cores +ncores = 10 + +# Self contained rundir +#self_contained = false + +# Post-processing to generate statistics and plots +postprocess = true + +# Cleaning +clean = true + +# molecules to be docked +molecules = [ + "${data.antibody.name}", + "${data.antigen.name}" + ] + +# ==================================================================== +# Parameters for each stage are defined below, prefer full paths +# ==================================================================== +[topoaa] + +[rigidbody] +# number of models to generate +sampling = 200 +# paratope to surface ambig restraints +ambig_fname = "${data.ambig_fname.name}" +# Restraints to keep the antibody chains together +unambig_fname = "${data.unambig_fname.name}" +# Turn off ramdom removal of restraints +# randremoval = false + +[clustfcc] +min_population = 10 + +[seletopclusts] +## select all the clusters +top_cluster = 500 +## select the best 10 models of each cluster +top_models = 10 + +[caprieval] +# this is only for this tutorial to check the performance at the rigidbody stage +reference_fname = "${data.reference_fname.name}" + +[flexref] +# Acceptable percentage of model failures +# tolerance = 5 +# paratope to surface ambig restraints +ambig_fname = "${data.ambig_fname.name}" +# Restraints to keep the antibody chains together +unambig_fname = "${data.unambig_fname.name}" +# Turn off ramdom removal of restraints +# randremoval = false + +[emref] +# paratope to surface ambig restraints +ambig_fname = "${data.ambig_fname.name}" +# Restraints to keep the antibody chains together +unambig_fname = "${data.unambig_fname.name}" +# Turn off ramdom removal of restraints +# randremoval = false + +[clustfcc] + +[seletopclusts] +top_cluster = 500 + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +# ==================================================================== + +`; +} + +async function createZip(workflow: string, data: Schema) { + const zip = new JSZip(); + zip.file(WORKFLOW_CONFIG_FILENAME, workflow); + zip.file(data.antibody.name, data.antibody); + zip.file(data.antigen.name, data.antigen); + zip.file(data.ambig_fname.name, data.ambig_fname); + zip.file(data.unambig_fname.name, data.unambig_fname); + zip.file(data.reference_fname.name, data.reference_fname); + return zip.generateAsync({ type: "blob" }); +} + +export default function AntibodyAntigenScenario() { + const actionData = useActionData(); + const submit = useSubmit(); + const navigate = useNavigate(); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + const data = parseFormData(formData, Schema); + const workflow = generateWorkflow(data); + const zipPromise = createZip(workflow, data); + handleActionButton(event.nativeEvent, zipPromise, navigate, submit); + } + + return ( + <> +

Antibody-antigen scenario

+

+ Based on{" "} + + HADDOCK3 Antibody Antigen tutorial + + . +

+
+
+ + + + In tutorial named pdbs/4G6K_clean.pdb + + + + + + In tutorial named pdbs/4I1B_clean.pdb + + + + + + In tutorial named restraints/ambig-paratope-NMR-epitope-pass.tbl + + + + + + In tutorial named restraints/antibody-unambig.tbl + + + + + + In tutorial named pdbs/4G6M_matched.pdb + + +
+
+ {actionData?.errors.map((error) => ( +

{error}

+ ))} +
+ + + + ); +} diff --git a/app/routes/scenarios.protein-protein.tsx b/app/routes/scenarios.protein-protein.tsx new file mode 100644 index 00000000..1493e117 --- /dev/null +++ b/app/routes/scenarios.protein-protein.tsx @@ -0,0 +1,184 @@ +import { useActionData, useSubmit, useNavigate } from "@remix-run/react"; +import { action as uploadaction } from "./upload"; +import { ActionButtons, handleActionButton } from "~/scenarios/actions"; +import { object, instance, Output } from "valibot"; +import { parseFormData } from "~/scenarios/schema"; +import { WORKFLOW_CONFIG_FILENAME } from "~/bartender-client/constants"; +import JSZip from "jszip"; +import { FormDescription } from "~/scenarios/FormDescription"; +import { FormItem } from "~/scenarios/FormItem"; +import { PDBFileInput } from "~/scenarios/PDBFileInput"; +import { Input } from "~/components/ui/input"; + +export const action = uploadaction; + +const Schema = object({ + protein1: instance(File, "First protein structure as PDB file"), + protein2: instance(File, "Second protein structure as PDB file"), + ambig_fname: instance(File, "Ambiguous restraints as TBL file"), + reference_fname: instance(File, "Reference structure as PDB file"), +}); +type Schema = Output; + +function generateWorkflow(data: Schema) { + // Workflow based on + // https://github.com/haddocking/haddock3/blob/main/examples/docking-protein-protein/docking-protein-protein-full.cfg + // made valid for easy expertise level + return ` +# ==================================================================== +# Protein-protein docking example with NMR-derived ambiguous interaction restraints + +# directory in which the scoring will be done +run_dir = "run1-full" + +# execution mode +mode = "batch" +# it will take the system's default +# queue = "short" +# concatenate models inside each job, concat = 5 each .job will produce 5 models +concat = 5 +# Limit the number of concurrent submissions to the queue +queue_limit = 100 + +# molecules to be docked +molecules = [ + "${data.protein1.name}", + "${data.protein2.name}" + ] + +# ==================================================================== +# Parameters for each stage are defined below, prefer full paths +# ==================================================================== +[topoaa] + +[rigidbody] +ambig_fname = "${data.ambig_fname.name}" +sampling = 1000 + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +[seletop] +select = 200 + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +[flexref] +ambig_fname = "${data.ambig_fname.name}" + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +[emref] +ambig_fname = "${data.ambig_fname.name}" + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +[clustfcc] + +[seletopclusts] +top_models = 4 + +[caprieval] +reference_fname = "${data.reference_fname.name}" + +# ==================================================================== + + + `; +} + +async function createZip(workflow: string, data: Schema) { + const zip = new JSZip(); + zip.file(WORKFLOW_CONFIG_FILENAME, workflow); + zip.file(data.protein1.name, data.protein1); + zip.file(data.protein2.name, data.protein2); + zip.file(data.ambig_fname.name, data.ambig_fname); + zip.file(data.reference_fname.name, data.reference_fname); + return zip.generateAsync({ type: "blob" }); +} + +export default function ProteinProteinScenario() { + const actionData = useActionData(); + const submit = useSubmit(); + const navigate = useNavigate(); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + const data = parseFormData(formData, Schema); + const workflow = generateWorkflow(data); + const zipPromise = createZip(workflow, data); + handleActionButton(event.nativeEvent, zipPromise, navigate, submit); + } + + return ( + <> +

Protein-protein docking scenario

+

+ Based on{" "} + + HADDOCK2.4 Protein-protein docking tutorial + {" "} + and the{" "} + + HADDOCK3 example + + . +

+
+
+ + + + In example named data/e2aP_1F3G.pdb + + + + + + In example named data/hpr_ensemble.pdb + + + + + + In example named data/e2a-hpr_air.tbl + + + + + + In example named data/e2a-hpr_1GGR.pdb + + +
+
+ {actionData?.errors.map((error) => ( +

{error}

+ ))} +
+ + + + ); +} diff --git a/app/scenarios/FormDescription.tsx b/app/scenarios/FormDescription.tsx new file mode 100644 index 00000000..c52ce1cf --- /dev/null +++ b/app/scenarios/FormDescription.tsx @@ -0,0 +1,5 @@ +import { PropsWithChildren } from "react"; + +export function FormDescription({ children }: PropsWithChildren): JSX.Element { + return

{children}

; +} diff --git a/app/scenarios/FormItem.tsx b/app/scenarios/FormItem.tsx new file mode 100644 index 00000000..b295ec58 --- /dev/null +++ b/app/scenarios/FormItem.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from "react"; +import { FlatErrors } from "valibot"; +import { ErrorMessages } from "~/components/ErrorMessages"; +import { Label } from "~/components/ui/label"; + +export function FormItem({ + name, + label, + children, + errors, +}: PropsWithChildren<{ + name: string; + label: string; + errors?: FlatErrors; +}>) { + return ( +
+ + {children} + {errors && } +
+ ); +} diff --git a/app/scenarios/PDBFileInput.tsx b/app/scenarios/PDBFileInput.tsx new file mode 100644 index 00000000..fbfb9056 --- /dev/null +++ b/app/scenarios/PDBFileInput.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Viewer } from "~/scenarios/Viewer.client"; +import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog"; + +export function PDBFileInput({ + name, + required, +}: { + name: string; + required?: boolean; +}) { + const [file, setFile] = useState(undefined); + const [open, setOpen] = useState(false); + + function onChange(event: React.ChangeEvent) { + event.preventDefault(); + setFile(event.target.files?.[0]); + } + + return ( +
+ + + + + + + {file !== undefined && open && } + + +
+ ); +} diff --git a/app/scenarios/Viewer.client.tsx b/app/scenarios/Viewer.client.tsx new file mode 100644 index 00000000..18c9d708 --- /dev/null +++ b/app/scenarios/Viewer.client.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from "react"; +import { Stage } from "ngl"; + +export function Viewer({ file }: { file: File | undefined }) { + const viewportRef = useRef(null); + const stage = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + if (isLoaded) { + return; + } + if (!viewportRef.current) { + return; + } + + // Create Stage object + stage.current = new Stage(viewportRef.current); + let backgroundColor = "white"; + if (document?.documentElement?.classList.contains("dark")) { + backgroundColor = "black"; + } else if (document?.documentElement?.classList.contains("light")) { + backgroundColor = "white"; + } else if (window?.matchMedia("(prefers-color-scheme: dark)").matches) { + backgroundColor = "black"; + } + stage.current.setParameters({ backgroundColor }); + + if (stage.current === null || file === undefined) { + return; + } + stage.current.loadFile(file, { ext: "pdb" }).then((o) => { + if (o === undefined) { + console.error("Could not load file"); + return; + } + o.addRepresentation("cartoon", { sele: "protein" }); + o.addRepresentation("ball+stick", { sele: "ligand" }); + o.addRepresentation("base", { sele: "nucleic" }); + + o.autoView(); + setIsLoaded(true); + }); + + // TODO clean up messes up the rendering, need to figure out why + // return () => { + // if (stage.current && file) { + // const comps = stage.current.getComponentsByName(file.name); + // comps.dispose(); + // } + // if (stage.current) { + // stage.current.dispose(); + // } + // }; + }, [file, isLoaded]); + + return
; +} diff --git a/app/scenarios/actions.tsx b/app/scenarios/actions.tsx new file mode 100644 index 00000000..818ba93a --- /dev/null +++ b/app/scenarios/actions.tsx @@ -0,0 +1,101 @@ +import { NavigateFunction, SubmitFunction } from "@remix-run/react"; +import { Button } from "~/components/ui/button"; + +export function doUpload(zipPromise: Promise, submit: SubmitFunction) { + zipPromise.then((zip) => { + // upload archive to server + const formData = new FormData(); + formData.set("upload", zip); + submit(formData, { + method: "post", + encType: "multipart/form-data", + }); + }); +} + +export function onRefine( + zipPromise: Promise, + navigate: NavigateFunction +) { + // add zip to browsers indexeddb haddock3 db, zips object store, key workflow.zip + if (typeof indexedDB === "undefined") { + console.error("IndexedDB not supported, unable to save workflow.zip file."); + return; + } + + const open = indexedDB.open("haddock3", 1); + open.onerror = function () { + console.error("Error opening indexeddb", open.error); + }; + open.onupgradeneeded = function () { + const db = open.result; + db.createObjectStore("zips"); + }; + open.onblocked = function () { + console.error("Error opening indexeddb, blocked"); + }; + open.onsuccess = function () { + zipPromise.then((zip) => { + const db = open.result; + const tx = db.transaction("zips", "readwrite"); + const zips = tx.objectStore("zips"); + const putRequest = zips.put(zip, "workflow.zip"); + putRequest.onerror = function () { + console.error("Error putting zip in indexeddb", putRequest.error); + }; + putRequest.onsuccess = function () { + navigate("/builder"); + }; + }); + }; +} + +export function onDownload(zipPromise: Promise) { + zipPromise.then((zip) => { + const url = URL.createObjectURL(zip); + const a = document.createElement("a"); + a.href = url; + a.download = "workflow.zip"; + a.click(); + URL.revokeObjectURL(url); + }); +} + +export function handleActionButton( + event: Event, + zipPromise: Promise, + navigate: NavigateFunction, + submit: SubmitFunction +) { + const submitEvent = event as SubmitEvent; + const kind = submitEvent.submitter?.getAttribute("value"); + // TODO validate zip against catalog + // now done on server for kind=upload + // or on /builder page in devtools console for kind=refine + if (kind === "refine") { + onRefine(zipPromise, navigate); + } else if (kind === "download") { + onDownload(zipPromise); + } else { + doUpload(zipPromise, submit); + } +} + +export function ActionButtons() { + return ( +
+ + + + +
+ ); +} diff --git a/app/scenarios/schema.ts b/app/scenarios/schema.ts new file mode 100644 index 00000000..ffe4deb0 --- /dev/null +++ b/app/scenarios/schema.ts @@ -0,0 +1,9 @@ +import { BaseSchema, parse } from "valibot"; + +export function parseFormData( + formData: FormData, + schema: T +) { + const obj = Object.fromEntries(formData.entries()); + return parse(schema, obj); +} diff --git a/package-lock.json b/package-lock.json index 4678c73d..88762bcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@i-vresse/wb-core": "^1.2.3", "@i-vresse/wb-form": "^1.1.3", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", @@ -36,6 +37,7 @@ "js-yaml": "^4.1.0", "jszip": "^3.10.1", "lucide-react": "^0.321.0", + "ngl": "^2.2.2", "openapi-fetch": "^0.8.2", "plotly.js": "^2.26.1", "postgres": "^3.4.3", @@ -2626,6 +2628,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", @@ -10101,6 +10139,14 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -15517,6 +15563,25 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sass": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.72.0.tgz", + "integrity": "sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sax": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", diff --git a/package.json b/package.json index 6c34e060..564dd60b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@i-vresse/wb-core": "^1.2.3", "@i-vresse/wb-form": "^1.1.3", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", @@ -60,6 +61,7 @@ "js-yaml": "^4.1.0", "jszip": "^3.10.1", "lucide-react": "^0.321.0", + "ngl": "^2.2.2", "openapi-fetch": "^0.8.2", "plotly.js": "^2.26.1", "postgres": "^3.4.3",