diff --git a/components/ScatterPlot/ScatterPlot.tsx b/components/ScatterPlot/ScatterPlot.tsx index 75572ec1e..86c860949 100644 --- a/components/ScatterPlot/ScatterPlot.tsx +++ b/components/ScatterPlot/ScatterPlot.tsx @@ -14,7 +14,7 @@ import { import dynamic from "next/dynamic"; import type { PlotDatum } from "plotly.js-basic-dist"; -import type { Molecule } from "../../features/SDFViewer"; +import type { Molecule } from "../../features/SDFViewer/SDFViewerData"; const Plot = dynamic( () => import("../../components/viz/Plot").then((mod) => mod.Plot), @@ -32,7 +32,7 @@ const getPropArrayFromMolecules = (molecules: Molecule[], prop: string | null) = if (prop === "id") { return molecules.map((molecule) => molecule.id); } - return molecules.map((molecule) => (prop ? molecule.properties[prop] ?? null : null)); + return molecules.map((molecule) => (prop ? molecule.properties[prop] : null)); }; type AxisSeries = ReturnType; diff --git a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx index 4e5c5d202..df8ef35e2 100644 --- a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx +++ b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx @@ -8,12 +8,12 @@ import { createColumnHelper } from "@tanstack/react-table"; import { CenterLoader } from "../../../../../../components/CenterLoader"; import { DataTable } from "../../../../../../components/DataTable/DataTable"; import { ModalWrapper } from "../../../../../../components/modals/ModalWrapper"; +import type { JSON_SCHEMA_TYPE } from "../../../../../../utils/app/jsonSchema"; +import { JSON_SCHEMA_TYPES } from "../../../../../../utils/app/jsonSchema"; import { getErrorMessage } from "../../../../../../utils/next/orvalError"; -import { JSON_SCHEMA_TYPES } from "./constants"; import { DatasetSchemaDescriptionInput } from "./DatasetSchemaDescriptionInput"; import { DatasetSchemaInputCell } from "./DatasetSchemaInputCell"; import { DatasetSchemaSelectCell } from "./DatasetSchemaSelectCell"; -import type { JSONSchemaType } from "./types"; import { useDatasetSchema } from "./useDatasetSchema"; type TableSchemaView = { @@ -23,8 +23,8 @@ type TableSchemaView = { current: string; }; type: { - original: JSONSchemaType; - current: JSONSchemaType; + original: JSON_SCHEMA_TYPE; + current: JSON_SCHEMA_TYPE; }; }; diff --git a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/types.ts b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/types.ts index 014c487b1..1d11ed255 100644 --- a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/types.ts +++ b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/types.ts @@ -1,13 +1,11 @@ import type { DatasetSchemaGetResponse } from "@squonk/data-manager-client"; -import type { JSON_SCHEMA_TYPES } from "./constants"; - -export type JSONSchemaType = (typeof JSON_SCHEMA_TYPES)[number]; +import type { JSON_SCHEMA_TYPE } from "../../../../../../utils/app/jsonSchema"; // These types should be defined in the OpenAPI but currently aren't export interface Field { description: string; - type: JSONSchemaType; + type: JSON_SCHEMA_TYPE; } export type Fields = Record; export type FieldKey = keyof Field; diff --git a/features/SDFViewer/ConfigEditor.tsx b/features/SDFViewer/ConfigEditor.tsx new file mode 100644 index 000000000..eadddd683 --- /dev/null +++ b/features/SDFViewer/ConfigEditor.tsx @@ -0,0 +1,184 @@ +import { Fragment } from "react"; +import type { SubmitHandler } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; + +import { Alert, Box, Button, Checkbox, MenuItem, TextField, Typography } from "@mui/material"; + +import type { SDFViewerConfig } from "../../utils/api/sdfViewer"; +import { type JSON_SCHEMA_TYPE, JSON_SCHEMA_TYPES } from "../../utils/app/jsonSchema"; + +type Field = { + type: JSON_SCHEMA_TYPE; + description: string; +}; + +interface Schema { + $schema: string; + $id: string; + title: string; + description: string; + version: number; + type: "object"; + fields: Record; + required: string[]; + labels: Record; +} + +export interface ConfigEditorProps { + schema: Schema; + config: SDFViewerConfig; + onChange: (config: SDFViewerConfig) => void; +} + +const getDefault = (field: string, dtype: JSON_SCHEMA_TYPE) => ({ + field, + dtype, + include: true, + cardView: true, + min: -Infinity, + max: Infinity, + sort: "ASC", +}); + +export const ConfigEditor = ({ schema, config, onChange }: ConfigEditorProps) => { + const { fields } = schema; + + const fieldsInConfig = Object.keys(config); + Object.keys(fields).forEach( + (field) => + !fieldsInConfig.includes(field) && (config[field] = getDefault(field, fields[field].type)), + ); + + const { control, register, watch, handleSubmit } = useForm({ + defaultValues: config, + }); + + if (Object.values(fields).length === 0) { + return No fields found in schema; + } + + const getStep = (field: string) => { + const type = watch(field).dtype; + switch (type) { + case "number": + return 0.1; + case "integer": + return 1; + default: + return undefined; + } + }; + + const getIsNumeric = (key: string) => + watch(key).dtype === "number" || watch(key).dtype === "integer"; + + // data isn't really of type SDFViewerConfig, inputs give string values instead of numbers, but + // our Infinity defaults are numbers + const onSubmit: SubmitHandler = (data) => onChange(data); + + // const onSubmit: SubmitHandler = (data) => console.log(data); + + return ( +
+ + + Field name + + + Type + + + Include + + + Card view + + + Min + + + Max + + + Sort + + + {Object.entries(fields).map(([key], index) => ( + + {key} + + {JSON_SCHEMA_TYPES.map((type) => ( + + {type} + + ))} + + + ( + field.onChange(e.target.checked)} + /> + )} + /> + ( + field.onChange(e.target.checked)} + /> + )} + /> + + + + + + + ASC + + + DESC + + + + ))} + + +
+ ); +}; diff --git a/features/SDFViewer/SDFViewer.tsx b/features/SDFViewer/SDFViewer.tsx new file mode 100644 index 000000000..382725f0e --- /dev/null +++ b/features/SDFViewer/SDFViewer.tsx @@ -0,0 +1,87 @@ +import type { PropsWithChildren } from "react"; +import { useState } from "react"; + +import type { DmError, ErrorType } from "@squonk/data-manager-client"; +import { useGetProjectFile } from "@squonk/data-manager-client/project"; + +import { Button, Typography } from "@mui/material"; + +import { CenterLoader } from "../../components/CenterLoader"; +import type { SDFViewerConfig } from "../../utils/api/sdfViewer"; +import { ConfigEditor } from "./ConfigEditor"; +import { SDFViewerData } from "./SDFViewerData"; + +export interface SDFViewerProps { + project: string; + path: string; + file: string; +} + +const getSchemaFileNameFromSDFFileName = (fname: string) => fname.slice(0, -4) + ".schema.json"; + +export const SDFViewer = ({ project, path, file }: SDFViewerProps) => { + const schemaFilename = getSchemaFileNameFromSDFFileName(file); + const { + data: schema, + error, + isLoading, + } = useGetProjectFile>(project, { + path, + file: schemaFilename, + }); + + const [isEditingConfig, setIsEditingConfig] = useState(true); + const [config, setConfig] = useState(undefined); + + if (error) { + // handle error + // SDF schema might not exist + return null; + } + + if (isLoading) { + // TODO: add loading page + return ( +
+ +
+ ); + } + + if (isEditingConfig || config === undefined) { + return ( +
+ { + setIsEditingConfig(false); + setConfig(config); + }} + /> +
+ ); + } + + return ( +
+ + +
+ ); +}; + +interface HeaderProps { + title: string; +} + +const Header = ({ title, children }: PropsWithChildren) => { + return ( + <> + + {title} + + {children} + + ); +}; diff --git a/features/SDFViewer.tsx b/features/SDFViewer/SDFViewerData.tsx similarity index 72% rename from features/SDFViewer.tsx rename to features/SDFViewer/SDFViewerData.tsx index fd95dc48c..b0d030c30 100644 --- a/features/SDFViewer.tsx +++ b/features/SDFViewer/SDFViewerData.tsx @@ -2,22 +2,15 @@ import { useMemo, useState } from "react"; import type { SDFRecord } from "@squonk/sdf-parser"; -import { - Alert, - AlertTitle, - Box, - InputLabel, - LinearProgress, - MenuItem, - Select, - Typography, -} from "@mui/material"; +import { Alert, AlertTitle, Box, LinearProgress, Typography } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import { nanoid } from "nanoid"; -import { MolCard } from "../components/MolCard"; -import CalculationsTable from "../components/MolCard/CalculationsTable"; -import { ScatterPlot } from "../components/ScatterPlot/ScatterPlot"; +import { MolCard } from "../../components/MolCard"; +import CalculationsTable from "../../components/MolCard/CalculationsTable"; +import { ScatterPlot } from "../../components/ScatterPlot/ScatterPlot"; +import type { SDFViewerConfig } from "../../utils/api/sdfViewer"; +import { censorConfig } from "../../utils/api/sdfViewer"; const getCards = (molecules: Must[], propsToHide: string[] = []) => { return molecules.slice(0, 50).map((molecule) => { @@ -51,11 +44,16 @@ const getPropArrayFromRecords = (molecules: Molecule[] | SDFRecord[]): string[] }, []); }; -const useSDFRecords = (project: string, path: string, file: string) => { +const useSDFRecords = (project: string, path: string, file: string, config: SDFViewerConfig) => { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - const url = `${basePath}/api/sdf-parser?project=${encodeURIComponent( + + const queryParams = new URLSearchParams({ project, - )}&path=${encodeURIComponent(path)}&file=${encodeURIComponent(file)}`; + path, + file, + config: censorConfig(config), + }); + const url = `${basePath}/api/sdf-parser?${queryParams.toString()}`; return useQuery( ["sdf", project, path, file], @@ -89,21 +87,24 @@ export const recordsToMolecules = (records: SDFRecord[]): Must[] => { })); }; -export interface SDFViewerProps { +export interface SDFViewerDataProps { project: string; path: string; file: string; + config: SDFViewerConfig; } -export const SDFViewer = ({ project, path, file }: SDFViewerProps) => { - const { data, isFetching, error } = useSDFRecords(project, path, file); +export const SDFViewerData = ({ project, path, file, config }: SDFViewerDataProps) => { + const { data, isFetching, error } = useSDFRecords(project, path, file, config); const records = useMemo(() => data ?? [], [data]); const molecules = useMemo(() => recordsToMolecules(records), [records]); const properties = useMemo(() => getPropArrayFromRecords(records), [records]); - const [propsToHide, setPropsToHide] = useState([]); + const propsToHide = Object.entries(config) + .filter(([_field, property]) => !property.cardView) + .map((entry) => entry[0]); const [selection, setSelection] = useState([]); @@ -142,27 +143,6 @@ export const SDFViewer = ({ project, path, file }: SDFViewerProps) => { width={600} /> - -

Config

- Properties to hide - -
{getCards( selection .map((id) => molecules.find((molecule) => molecule.id === id)) diff --git a/features/SDFViewer/index.ts b/features/SDFViewer/index.ts new file mode 100644 index 000000000..a101f2ed2 --- /dev/null +++ b/features/SDFViewer/index.ts @@ -0,0 +1 @@ +export * from "./SDFViewer"; diff --git a/package.json b/package.json index 071bf4cfd..fda071c6d 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@squonk/account-server-client": "2.0.9", "@squonk/data-manager-client": "1.2.5", "@squonk/mui-theme": "3.0.2", - "@squonk/sdf-parser": "1.1.1", + "@squonk/sdf-parser": "1.3.0", "@tanstack/match-sorter-utils": "8.8.4", "@tanstack/react-query": "4.36.1", "@tanstack/react-query-devtools": "4.36.1", @@ -93,6 +93,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "14.2.3", + "react-hook-form": "7.48.2", "react-plotly.js": "2.6.0", "sass": "1.69.5", "sharp": "0.32.6", diff --git a/pages/api/sdf-parser.tsx b/pages/api/sdf-parser.ts similarity index 59% rename from pages/api/sdf-parser.tsx rename to pages/api/sdf-parser.ts index 34158893c..3a527984e 100644 --- a/pages/api/sdf-parser.tsx +++ b/pages/api/sdf-parser.ts @@ -1,5 +1,5 @@ -import type { SDFRecord } from "@squonk/sdf-parser"; -import { NodeSDFTransformer } from "@squonk/sdf-parser"; +import type { FilterRule, SDFRecord } from "@squonk/sdf-parser"; +import { filterRecord, NodeSDFTransformer } from "@squonk/sdf-parser"; import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -7,14 +7,41 @@ import { Transform } from "node:stream"; import { createGunzip } from "node:zlib"; import fetch, { type Response } from "node-fetch"; +import type { SDFViewerConfig } from "../../utils/api/sdfViewer"; +import { uncensorConfig } from "../../utils/api/sdfViewer"; +import type { JSON_SCHEMA_TYPE } from "../../utils/app/jsonSchema"; import { API_ROUTES } from "../../utils/app/routes"; +const getTreatAs = (dtype: JSON_SCHEMA_TYPE): FilterRule["treatAs"] => { + switch (dtype) { + case "number": + case "integer": + return "number"; + default: + return "string"; + } +}; + type ResponseData = SDFRecord[] | { error: string }; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { method } = req; if (method === "GET") { - const { project: projectId, path, file: fileName } = req.query; + const { project: projectId, path, file: fileName, config: configString } = req.query; + + if (typeof configString !== "string") { + res.status(400).json({ error: "config must be a string" }); + return; + } + + let config: SDFViewerConfig; + try { + config = uncensorConfig(configString); + } catch (error) { + console.error(error); + res.status(400).json({ error: "config must be a valid JSON string" }); + return; + } if ( !projectId || @@ -40,11 +67,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) Authorization: `Bearer ${accessToken}`, }); - fetch( - process.env.DATA_MANAGER_API_SERVER + API_ROUTES.projectFile(projectId, path, fileName), - { headers }, - ); - response = await fetch( process.env.DATA_MANAGER_API_SERVER + API_ROUTES.projectFile(projectId, path, fileName), { headers }, @@ -76,7 +98,23 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) }, }); - stream.pipe(decoderTransform).pipe(new NodeSDFTransformer()).pipe(res); + const rules: FilterRule[] = Object.entries(config).map(([property, { min, max, dtype }]) => ({ + property, + min, + max, + treatAs: getTreatAs(dtype), + })); + + const excludedProperties = Object.entries(config) + .filter(([, { include }]) => !include) + .map(([property]) => property); + + const filter = (record: SDFRecord): boolean => filterRecord(record, rules); + + stream + .pipe(decoderTransform) + .pipe(new NodeSDFTransformer(filter, excludedProperties)) + .pipe(res); return; } res.status(response.status).json({ error: response.statusText }); diff --git a/pages/viewer/sdf.tsx b/pages/viewer/sdf.tsx index 221a2800d..4a343a20b 100644 --- a/pages/viewer/sdf.tsx +++ b/pages/viewer/sdf.tsx @@ -1,13 +1,13 @@ import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client"; -import { Container, Typography } from "@mui/material"; +import { Container } from "@mui/material"; import Error from "next/error"; import Head from "next/head"; import { useRouter } from "next/router"; import { RoleRequired } from "../../components/auth/RoleRequired"; import { AS_ROLES, DM_ROLES } from "../../constants/auth"; -import type { SDFViewerProps } from "../../features/SDFViewer"; import { SDFViewer } from "../../features/SDFViewer"; +import type { SDFViewerDataProps } from "../../features/SDFViewer/SDFViewerData"; import Layout from "../../layouts/Layout"; const SDF = () => { @@ -37,7 +37,7 @@ const SDF = () => { ); }; -const SDFView = ({ project, path, file }: SDFViewerProps) => { +const SDFView = ({ project, path, file }: Omit) => { // path and file to display const filePath = path === "" ? file : path + "/" + file; @@ -51,9 +51,7 @@ const SDFView = ({ project, path, file }: SDFViewerProps) => { Squonk | {filePath} - - {filePath} - + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a74151ee5..94f11183d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,8 +69,8 @@ dependencies: specifier: 3.0.2 version: 3.0.2(@mui/material@5.14.18) '@squonk/sdf-parser': - specifier: 1.1.1 - version: 1.1.1 + specifier: 1.3.0 + version: 1.3.0 '@tanstack/match-sorter-utils': specifier: 8.8.4 version: 8.8.4 @@ -182,6 +182,9 @@ dependencies: react-dropzone: specifier: 14.2.3 version: 14.2.3(react@18.2.0) + react-hook-form: + specifier: 7.48.2 + version: 7.48.2(react@18.2.0) react-plotly.js: specifier: 2.6.0 version: 2.6.0(plotly.js@2.27.1)(react@18.2.0) @@ -1511,8 +1514,8 @@ packages: '@mui/material': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) dev: false - /@squonk/sdf-parser@1.1.1: - resolution: {integrity: sha512-jGHo405vUb039kekGDaJDRx48A7UEvv9cHSL2qpKZOTvHPto3acNber2LQ1+OWQOhJU0sgZoqgzl15rOwWUimA==} + /@squonk/sdf-parser@1.3.0: + resolution: {integrity: sha512-vnQ1WUOdW8FUtQtUAZP0R/KRf6YzusMSrLdup6esOirRagAc28EUSwRDz30sue6zrWynFuTMY0MElVkC6BgCag==} dev: false /@swc/helpers@0.5.2: @@ -7001,6 +7004,15 @@ packages: resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==} dev: false + /react-hook-form@7.48.2(react@18.2.0): + resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-intersection-observer@8.34.0(react@18.2.0): resolution: {integrity: sha512-TYKh52Zc0Uptp5/b4N91XydfSGKubEhgZRtcg1rhTKABXijc4Sdr1uTp5lJ8TN27jwUsdXxjHXtHa0kPj704sw==} peerDependencies: diff --git a/utils/api/sdfViewer.ts b/utils/api/sdfViewer.ts new file mode 100644 index 000000000..fd4e13cb9 --- /dev/null +++ b/utils/api/sdfViewer.ts @@ -0,0 +1,46 @@ +import type { JSON_SCHEMA_TYPE } from "../app/jsonSchema"; + +export interface ConfigForProperty { + field: string; + dtype: JSON_SCHEMA_TYPE; + include: boolean; + cardView: boolean; + min: number; + max: number; + sort: string; +} + +export type SDFViewerConfig = Record; + +export const censorConfig = (config: SDFViewerConfig): string => + JSON.stringify(config, (key, value) => { + if (key === "min") { + if (value === "") { + value = -Infinity; + } + if (value === -Infinity) { + return { isInfinity: true, sign: -1 }; + } + } + if (key === "max") { + if (value === "") { + value = Infinity; + } + if (value === Infinity) { + return { isInfinity: true, sign: +1 }; + } + } + + return value; + }); + +export const uncensorConfig = (config: string): SDFViewerConfig => + JSON.parse(config, (key, value) => { + if (key === "min" || key === "max") { + if (typeof value === "object" && value !== null && value.isInfinity) { + return value.sign * Infinity; + } + } + + return value; + }); diff --git a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/constants.ts b/utils/app/jsonSchema.ts similarity index 65% rename from features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/constants.ts rename to utils/app/jsonSchema.ts index 7413dd1fe..f7544d6b3 100644 --- a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/constants.ts +++ b/utils/app/jsonSchema.ts @@ -7,3 +7,5 @@ export const JSON_SCHEMA_TYPES = [ "boolean", "null", ] as const; + +export type JSON_SCHEMA_TYPE = (typeof JSON_SCHEMA_TYPES)[number];