Skip to content
Open
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
2,217 changes: 153 additions & 2,064 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@
"@mat3ra/ade": "2026.8.18-0",
"@mat3ra/code": "2026.8.18-0",
"@mat3ra/esse": "2026.8.18-2",
"@mat3ra/ide": "https://github.com/mat3ra/ide/releases/download/wip-88b8881/ide.tgz",
"@mat3ra/made": "https://github.com/mat3ra/made/releases/download/wip-b5ab8c9/made.tgz",
"@mat3ra/mode": "https://github.com/mat3ra/mode/releases/download/wip-f8543e7/mode.tgz",
"@mat3ra/ide": "2026.8.18-0",
"@mat3ra/made": "2026.8.18-0",
"@mat3ra/mode": "2026.8.18-0",
"@mat3ra/prode": "2026.8.18-0",
"@mat3ra/standata": "2026.8.18-0",
"@mat3ra/tsconfig": "^2024.6.3-0",
Expand Down
76 changes: 52 additions & 24 deletions src/components/ExecutionUnit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Radio from "@mui/material/Radio";
import Stack from "@mui/material/Stack";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";

import { Application } from "./Application";
import {
type ExecutionUnitInput,
ExecutionUnitInputFilePanel,
} from "./ExecutionUnitInputFilePanel";
import { TemplateVariablesPanel } from "./TemplateVariablesPanel";
import {
DrawerContainer,
DrawerContent,
Expand All @@ -30,6 +31,7 @@ import {
} from "./SideDrawer";

import TabsMenu from "@mat3ra/cove/dist/mui/components/tabs/TabsMenu";
import { findUnresolvedVariables } from "../utils/templateVariables";
import type { ExecutableSchema, FlavorSchema } from "@mat3ra/esse/dist/js/types";


Expand All @@ -51,6 +53,8 @@ export type ExecutionUnitProps = {
availableUnits: AnySubworkflowUnit[];
onChange: (value: string) => void;
}>;
/** Labels for top-level rendering-context keys, when the host knows better than the default. */
variableOriginOverrides?: Record<string, string>;
/** Injected component for unit details (results, monitors, post-processors). */
UnitDetailsComponent?: React.ComponentType<{
unit: ExecutionUnitSchema;
Expand Down Expand Up @@ -81,6 +85,7 @@ export function ExecutionUnit({
units = [],
UnitPointerFieldComponent,
UnitDetailsComponent,
variableOriginOverrides,
}: ExecutionUnitProps) {
const [drawerContent, setDrawerContent] = useState<"context" | "materialList">("context");
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
Expand Down Expand Up @@ -264,6 +269,19 @@ export function ExecutionUnit({
[unit, onUpdate],
);

/**
* nunjucks substitutes an unknown variable with the empty string, so a typo does not fail —
* it quietly drops a value out of the input file. Checked per input so the warning can sit
* with the template it belongs to.
*/
const issuesByInput = useMemo(
() =>
unit.input.map((inputRow: ExecutionUnitInput) =>
findUnresolvedVariables(inputRow.template?.content, renderingContext),
),
[unit.input, renderingContext],
);

const tabs = unit.input.map((inputRow: ExecutionUnitInput, index: number) => {
const label = inputRow.template.name;
return {
Expand All @@ -283,17 +301,6 @@ export function ExecutionUnit({

return (
<Stack spacing={2} className="ExecutionUnit" sx={{ py: 2 }}>
<Stack className="ExecutionUnit-Stack" direction="column" spacing={2}>
{UnitPointerFieldComponent && unitPointerKeys.map((key) => (
<UnitPointerFieldComponent
key={key}
label={key}
selectedValue={unit[key] ?? ""}
availableUnits={availableUnits()}
onChange={(value) => handleUnitKeyUpdate(key, value)}
/>
))}
</Stack>
<AccordionComponent
className="ExecutionUnit-Accordion"
id="execution-unit-details-accordion"
Expand Down Expand Up @@ -353,43 +360,40 @@ export function ExecutionUnit({
lineWrapping={lineWrapping}
adjustable={adjustable}
isStandalone={isStandalone}
issues={issuesByInput[index]}
/>
))}
</MainContent>
<SideDrawer open={isDrawerVisible}>
<DrawerControlPanel>
<DrawerControl
isToggleAnchor
data-tid="unit-drawer-toggle"
onClick={() => setIsDrawerVisible((v) => !v)}
icon={isDrawerVisible ? "shapes.arrow.right" : "shapes.arrow.left"}
/>
<Divider />
<DrawerControl
active={drawerContent === "context"}
data-tid="unit-drawer-variables"
onClick={() => setDrawerContent("context")}
icon="pages.context"
/>
<DrawerControl
active={drawerContent === "materialList"}
data-tid="unit-drawer-material"
onClick={() => setDrawerContent("materialList")}
icon="entities.material"
/>
</DrawerControlPanel>
<DrawerContent
open={isDrawerVisible}
title={drawerContent === "context" ? "Context" : "Material"}>
title={drawerContent === "context" ? "Variables" : "Material"}>
{drawerContent === "context" && (
<pre
className="visible-rendering-context"
style={{
whiteSpace: "pre-wrap",
margin: 0,
padding: 10,
minHeight: "100%",
border: "none",
}}>
{JSON.stringify(renderingContext ?? {}, null, " ")}
</pre>
<TemplateVariablesPanel
renderingContext={renderingContext}
originOverrides={variableOriginOverrides}
/>
)}
{drawerContent === "materialList" && (
<List key="material-label-ul">
Expand Down Expand Up @@ -424,6 +428,30 @@ export function ExecutionUnit({
</SideDrawer>
</DrawerContainer>
</AccordionComponent>
{/*
Execution order is a wiring detail, not what someone opens a unit to change: it
used to sit above everything, so the first thing the input editor showed was a
pointer to another unit. It keeps its place in the form, at the end and collapsed.
*/}
{UnitPointerFieldComponent && (
<AccordionComponent
className="ExecutionUnit-Accordion"
id="execution-unit-advanced-accordion"
header="Advanced"
disableGutters>
<Stack className="ExecutionUnit-Stack" direction="column" spacing={2}>
{unitPointerKeys.map((key) => (
<UnitPointerFieldComponent
key={key}
label={key}
selectedValue={unit[key] ?? ""}
availableUnits={availableUnits()}
onChange={(value) => handleUnitKeyUpdate(key, value)}
/>
))}
</Stack>
</AccordionComponent>
)}
</Stack>
);
}
22 changes: 22 additions & 0 deletions src/components/ExecutionUnitInputFilePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import CodeMirror from "@mat3ra/cove/dist/other/codemirror";
import Alert from "@mui/material/Alert";
import { getProgrammingLanguageFromFileExtension } from "@mat3ra/code/dist/js/utils";
import type { ExecutionUnitInputItemSchema } from "@mat3ra/esse/dist/js/types";
import Stack from "@mui/material/Stack";
import React from "react";

import TabsMenu from "@mat3ra/cove/dist/mui/components/tabs/TabsMenu";
import type { TabItem } from "@mat3ra/cove/dist/mui/components/tabs/types";
import type { TemplateIssue } from "../utils/templateVariables";

/** `input[]` row for an execution unit; optional `name` is set by `setInputItemNameByIndex` for tab labels. */
export type ExecutionUnitInput = ExecutionUnitInputItemSchema & {
Expand All @@ -26,6 +28,8 @@ type ExecutionUnitInputFilePanelProps = {
lineWrapping: boolean;
adjustable?: boolean;
isStandalone?: boolean;
/** Variables in this template that will render to nothing. */
issues?: TemplateIssue[];
};

const codeMirrorDefaults: Record<string, unknown> = {
Expand All @@ -50,6 +54,7 @@ export function ExecutionUnitInputFilePanel({
lineWrapping,
adjustable,
isStandalone,
issues = [],
}: ExecutionUnitInputFilePanelProps) {
const contentTabIdString = `template-${index}`;
const previewTabIdString = `preview-${index}`;
Expand Down Expand Up @@ -84,6 +89,23 @@ export function ExecutionUnitInputFilePanel({
id={String(index)}
className={`ExecutionFile ${isActive ? "active" : ""}`}>
<TabsMenu tabs={fileTabs} activeTabIndex={activeInnerTabIndex} sx={{ fontSize: 12 }} />
{/*
Named here rather than left to the preview: nunjucks renders an unknown variable
as the empty string, so the preview of a typo looks like a value that is simply
blank.
*/}
{issues.length > 0 && (
<Alert severity="warning" data-tid="template-issues" sx={{ py: 0.25 }}>
{issues
.map(
(issue) =>
`line ${issue.line}: ${issue.name}${
issue.suggestion ? ` — did you mean ${issue.suggestion}?` : ""
}`,
)
.join(" · ")}
</Alert>
)}
<Stack
display={activeInnerTabIndex === 0 ? undefined : "none"}
spacing={2}
Expand Down
160 changes: 160 additions & 0 deletions src/components/TemplateVariablesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Box from "@mui/material/Box";
import InputAdornment from "@mui/material/InputAdornment";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import React, { useMemo, useState } from "react";

import { type ContextVariable, flattenRenderingContext } from "../utils/templateVariables";

export interface TemplateVariablesPanelProps {
renderingContext?: Record<string, unknown>;
/** Host-supplied labels for top-level context keys; see `describeOrigin`. */
originOverrides?: Record<string, string>;
}

/** "renders with: Si … · espresso", from the context itself rather than a separate prop. */
function describeRenderTarget(context?: Record<string, unknown>): string {
const read = (key: string) => {
const value = (context?.[key] ?? {}) as { name?: unknown };
return typeof value?.name === "string" ? value.name : undefined;
};
return [read("material"), read("application")].filter(Boolean).join(" · ");
}

function groupByOrigin(variables: ContextVariable[]): [string, ContextVariable[]][] {
const groups = new Map<string, ContextVariable[]>();
variables.forEach((variable) => {
const bucket = groups.get(variable.origin);
if (bucket) bucket.push(variable);
else groups.set(variable.origin, [variable]);
});
return [...groups.entries()];
}

/**
* What a template can write, and where each value came from.
*
* Replaces a `<pre>` of the whole rendering context as JSON — everything available, in the shape
* it happened to be stored in, with no indication of which parts came from the Settings tab and
* which arrive at job runtime. Clicking a row copies the `{{ … }}` expression: inserting at the
* cursor needs the editor's `EditorView`, which cove's CodeMirror wrapper does not expose.
*
* Unresolved variables are reported by {@link ExecutionUnitInputFilePanel}, directly above the
* template they are in, rather than here as well.
*/
export function TemplateVariablesPanel({
renderingContext,
originOverrides,
}: TemplateVariablesPanelProps) {
const [search, setSearch] = useState("");
const [copied, setCopied] = useState<string | null>(null);

const variables = useMemo(
() => flattenRenderingContext(renderingContext, { originOverrides }),
[renderingContext, originOverrides],
);

const needle = search.trim().toLowerCase();
const visible = needle
? variables.filter(
(variable) =>
variable.path.toLowerCase().includes(needle) ||
variable.preview.toLowerCase().includes(needle),
)
: variables;

const copy = (path: string) => {
const expression = `{{ ${path} }}`;
navigator.clipboard?.writeText(expression).catch(() => undefined);
setCopied(path);
// Fire-and-forget; the panel only needs the label to settle back.
setTimeout(() => setCopied((current) => (current === path ? null : current)), 1200);
};

const renderTarget = describeRenderTarget(renderingContext);

return (
<Stack spacing={1.5} sx={{ p: 1.5 }} data-tid="template-variables-panel">
{renderTarget ? (
<Typography variant="caption" color="text.secondary">
renders with: {renderTarget}
</Typography>
) : null}

<TextField
size="small"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search variables"
InputProps={{
startAdornment: <InputAdornment position="start">⌕</InputAdornment>,
inputProps: { "data-tid": "template-variables-search" },
}}
/>

{visible.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{variables.length === 0
? "No context to render this template with yet."
: `Nothing matches “${search}”.`}
</Typography>
) : (
groupByOrigin(visible).map(([origin, rows]) => (
<Box key={origin}>
<Typography variant="overline" color="text.secondary">
{origin}
</Typography>
{rows.map((variable) => (
<Tooltip
key={variable.path}
title={
copied === variable.path
? "Copied"
: `Copy {{ ${variable.path} }}`
}
>
<Box
onClick={() => copy(variable.path)}
data-tid={`template-variable-${variable.path}`}
sx={{
display: "flex",
gap: 1,
alignItems: "baseline",
px: 0.75,
py: 0.25,
borderRadius: 0.5,
cursor: "pointer",
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Typography
variant="caption"
sx={{
fontFamily: "monospace",
fontWeight: variable.isLeaf ? 400 : 600,
whiteSpace: "nowrap",
}}
>
{variable.path}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ minWidth: 0 }}
>
{variable.preview}
</Typography>
</Box>
</Tooltip>
))}
</Box>
))
)}
</Stack>
);
}

export default TemplateVariablesPanel;
Loading