Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/gui-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Lint
run: npx eslint src --ext .ts,.tsx,.js

- name: Check SmilesDrawer version attribution
run: npm run check:smiles-drawer-version

- name: Test
run: npm test -- --watchAll=false
env:
Expand Down
3 changes: 2 additions & 1 deletion gui/src/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test"
"test": "craco test",
"check:smiles-drawer-version": "node scripts/checkSmilesDrawerVersion.js"
},
"eslintConfig": {
"extends": [
Expand Down
40 changes: 40 additions & 0 deletions gui/src/client/scripts/checkSmilesDrawerVersion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// Fails if DrawingAttribution.tsx's hardcoded SMILES_DRAWER_VERSION drifts from the
// version npm actually resolved in package-lock.json. Needed because smiles-drawer's
// package.json is blocked from import (its own "exports" field), so the "Drawn with
// SmilesDrawer vX" caption can't read the version at runtime the way ReactionScheme
// does for RDKit -- see DrawingAttribution.tsx for that comparison.
const fs = require("fs");
const path = require("path");

const root = path.join(__dirname, "..");

const lockfile = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
const resolvedVersion = lockfile.packages?.["node_modules/smiles-drawer"]?.version;

if (!resolvedVersion) {
console.error("checkSmilesDrawerVersion: could not find node_modules/smiles-drawer in package-lock.json");
process.exit(1);
}

const attributionPath = path.join(root, "src/components/DrawingAttribution.tsx");
const attributionSource = fs.readFileSync(attributionPath, "utf8");
const match = attributionSource.match(/SMILES_DRAWER_VERSION\s*=\s*"([^"]+)"/);

if (!match) {
console.error(`checkSmilesDrawerVersion: could not find SMILES_DRAWER_VERSION in ${attributionPath}`);
process.exit(1);
}

const hardcodedVersion = match[1];

if (hardcodedVersion !== resolvedVersion) {
console.error(
`checkSmilesDrawerVersion: DrawingAttribution.tsx says smiles-drawer v${hardcodedVersion}, ` +
`but package-lock.json resolves it to v${resolvedVersion}. ` +
`Update SMILES_DRAWER_VERSION in src/components/DrawingAttribution.tsx to match.`
);
process.exit(1);
}

console.log(`checkSmilesDrawerVersion: OK (v${resolvedVersion})`);
32 changes: 32 additions & 0 deletions gui/src/client/src/components/DrawingAttribution.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from "react";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";

// smiles-drawer's package.json is blocked from import by its own "exports" field, so
// this is kept in sync by hand with the resolved version in package-lock.json --
// scripts/checkSmilesDrawerVersion.js fails CI/lint if the two drift apart.
const SMILES_DRAWER_VERSION = "2.4.1";

type DrawingAttributionProps =
| { library: "smiles-drawer"; sx?: SxProps<Theme> }
// RDKit drawings are produced server-side (see gui/src/server/routes/rules.py), so
// there's no local constant to hardcode -- the version is only known once the
// caller has actually fetched a drawing and the server reported what rendered it.
| { library: "rdkit"; version: string; sx?: SxProps<Theme> };

// A single caption attributing one or more structure/reaction drawings above it to the
// library that rendered them. Place once per diagram -- if a diagram contains multiple
// drawings from the same library (e.g. a compound plus its reconstructions), attribute
// the whole group once rather than repeating this per drawing.
export const DrawingAttribution: React.FC<DrawingAttributionProps> = (props) => {
const label = props.library === "smiles-drawer" ? `SmilesDrawer v${SMILES_DRAWER_VERSION}` : `RDKit v${props.version}`;
return (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", fontStyle: "italic", ...props.sx }}
>
Drawn with {label}
</Typography>
);
};
6 changes: 5 additions & 1 deletion gui/src/client/src/components/MotifHoverCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { fetchMotifStructures } from "../features/motifs/api";
import { MotifName } from "./MotifName";
import SmilesDrawerContainer from "./SmilesDrawerContainer.js";
import { DrawingAttribution } from "./DrawingAttribution";

const DRAWING_SIZE = 100;

Expand Down Expand Up @@ -38,7 +39,10 @@ function MotifHoverContent({ name, hint }: { name: string; hint?: string }) {
return (
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0.5, maxWidth: 200, py: 0.5 }}>
{smiles ? (
<SmilesDrawerContainer identifier={`motif-hover-${reactId}`} smiles={smiles} size={DRAWING_SIZE} />
<>
<SmilesDrawerContainer identifier={`motif-hover-${reactId}`} smiles={smiles} size={DRAWING_SIZE} />
<DrawingAttribution library="smiles-drawer" sx={{ fontSize: "0.65rem" }} />
</>
) : (
<Box
sx={{
Expand Down
2 changes: 2 additions & 0 deletions gui/src/client/src/components/workspace/DialogViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DialogWindow } from "../DialogWindow";
import { ErrorBoundary } from "../ErrorBoundary";
import { ExportImageButton } from "../ExportImageButton";
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
import { DrawingAttribution } from "../DrawingAttribution";
import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor";
import { ClusterReadoutRows } from "./ClusterReadoutRows";

Expand Down Expand Up @@ -440,6 +441,7 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
</>
)}
</Box>
<DrawingAttribution library="smiles-drawer" sx={{ textAlign: "center" }} />
{hasReconstructions && (
<DescriptionBox
title={'Explanation'}
Expand Down
16 changes: 10 additions & 6 deletions gui/src/client/src/components/workspace/ReactionScheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import DOMPurify from "dompurify";
import { useColorScheme } from "@mui/material/styles";
import { useQuery } from "@tanstack/react-query";
import { fetchReactionSchemeSvg } from "../../features/rules/api";
import { DrawingAttribution } from "../DrawingAttribution";

export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
const { mode, systemMode } = useColorScheme();
Expand All @@ -31,7 +32,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
// Server-rendered SVG markup gets injected raw via dangerouslySetInnerHTML, so it
// must be sanitized first -- same rationale/profile as SvgViewer.
const sanitizedSvg = React.useMemo(
() => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data, { USE_PROFILES: { svg: true, svgFilters: true } }) : null),
() => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data.svg, { USE_PROFILES: { svg: true, svgFilters: true } }) : null),
[svgQuery.data]
);

Expand All @@ -43,7 +44,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
);
}

if (svgQuery.error || !sanitizedSvg) {
if (svgQuery.error || !sanitizedSvg || !svgQuery.data) {
return (
<Alert severity="warning" variant="outlined" sx={{ py: 0 }}>
Could not render this reaction ({smarts}).
Expand All @@ -52,9 +53,12 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
}

return (
<Box
sx={{ "& svg": { display: "block", maxWidth: "100%", height: "auto" } }}
dangerouslySetInnerHTML={{ __html: sanitizedSvg }}
/>
<>
<Box
sx={{ "& svg": { display: "block", maxWidth: "100%", height: "auto" } }}
dangerouslySetInnerHTML={{ __html: sanitizedSvg }}
/>
<DrawingAttribution library="rdkit" version={svgQuery.data.rdkitVersion} />
</>
);
}
14 changes: 9 additions & 5 deletions gui/src/client/src/components/workspace/WorkspaceRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { MinimalIconButton } from "../MinimalIconButton";
import { MotifName } from "../MotifName";
import { horizontalScrollSx } from "../../theme/scrollbarSx";
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
import { DrawingAttribution } from "../DrawingAttribution";
import { ReactionScheme } from "./ReactionScheme";

const STRUCTURE_SIZE = 130;
Expand Down Expand Up @@ -95,11 +96,14 @@ function MatchingRuleRow({ rule }: { rule: MatchingRule }) {

<Collapse in={expanded} unmountOnExit>
<Stack direction="row" spacing={2} sx={{ mt: 1, pl: 1 }}>
<SmilesDrawerContainer
identifier={`matching-rule-${rule.id}`}
smiles={rule.displaySmiles || rule.smiles}
size={STRUCTURE_SIZE}
/>
<Box>
<SmilesDrawerContainer
identifier={`matching-rule-${rule.id}`}
smiles={rule.displaySmiles || rule.smiles}
size={STRUCTURE_SIZE}
/>
<DrawingAttribution library="smiles-drawer" />
</Box>

<Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
<Box>
Expand Down
10 changes: 4 additions & 6 deletions gui/src/client/src/features/rules/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { getJson } from "../http";
import { RuleSetRespSchema, type RuleSetResp } from "./types";
import { ItemDrawingResultSchema } from "../drawing/types";
import { RuleSetRespSchema, ReactionSchemeSvgRespSchema, type RuleSetResp, type ReactionSchemeSvgResp } from "./types";

// The whole default rule set, fetched once and cached by the caller (see
// WorkspaceRules) -- it's small (a few hundred rules total) and effectively static
Expand All @@ -15,11 +14,10 @@ export async function fetchReactionSchemeSvg(
ruleId: string,
theme: "light" | "dark",
signal?: AbortSignal
): Promise<string> {
const data = await getJson(
): Promise<ReactionSchemeSvgResp> {
return getJson(
`/api/reactionSchemeSvg/${encodeURIComponent(ruleId)}?theme=${theme}`,
ItemDrawingResultSchema,
ReactionSchemeSvgRespSchema,
signal
);
return data.svg;
}
10 changes: 10 additions & 0 deletions gui/src/client/src/features/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ export const RuleSetRespSchema = z.object({
reactionRules: z.array(ReactionRuleSchema),
});
export type RuleSetResp = z.output<typeof RuleSetRespSchema>;

export const ReactionSchemeSvgRespSchema = z.object({
svg: z.string(),
// The RDKit version that rendered `svg` -- reported by the server (see
// routes/rules.py's reaction_scheme_svg) rather than pinned client-side, so the
// "Drawn with RDKit vX" attribution in ReactionScheme can never drift from what
// actually rendered it.
rdkitVersion: z.string(),
});
export type ReactionSchemeSvgResp = z.output<typeof ReactionSchemeSvgRespSchema>;
3 changes: 2 additions & 1 deletion gui/src/server/routes/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import threading

import rdkit
from flask import Blueprint, Response, jsonify, request
from rdkit.Chem.Draw import rdMolDraw2D

Expand Down Expand Up @@ -154,4 +155,4 @@ def reaction_scheme_svg(rule_id: str) -> tuple[Response, int]:
return jsonify({"error": "Unknown reaction rule id"}), 404

svg = _get_reaction_svg(rule, theme)
return jsonify({"svg": svg}), 200
return jsonify({"svg": svg, "rdkitVersion": rdkit.__version__}), 200
Loading