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
8 changes: 5 additions & 3 deletions src/diagram/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @simlin/diagram

Last verified: 2026-04-26
Last verified: 2026-05-11

React components for model visualization and editing. Designed as a general-purpose SD model editor toolkit without dependencies on the Simlin app or server API.

Expand All @@ -11,7 +11,9 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md).

### Editor and Core Logic

- `Editor.tsx` -- Main model editor: user interaction, state, and tool selection. Manages module navigation stack (`modelStack`), module CRUD handlers, and delegates to `ModuleDetails` for module editing. Optional `onSelectionChanged?: (idents: string[]) => void` prop fires after each selection change (used by `simlin-serve`'s `EditorHost` to forward selection state to backend listeners; `HostedWebEditor` in `src/app` does not subscribe). The callback runs through `setTimeout(..., 0)` so React commits the new selection before `getSelectionIdents()` reads `this.state.selection`.
- `Editor.tsx` -- Main model editor: user interaction, state, and tool selection. Manages module navigation stack (`modelStack`), module CRUD handlers, and delegates to `ModuleDetails` for module editing. Optional `onSelectionChanged?: (idents: string[]) => void` prop fires after each selection change (used by `simlin-serve`'s `EditorHost` to forward selection state to backend listeners; `HostedWebEditor` in `src/app` does not subscribe). The callback runs through `setTimeout(..., 0)` so React commits the new selection before `getSelectionIdents()` reads `this.state.selection`. Optional `onDeleteProject?: () => Promise<void>` prop: when set and not `readOnlyMode`, `getDrawer()` forwards it to `ModelPropertiesDrawer` as the drawer's destructive "Delete project" action (hosts backed by a non-deletable project -- `simlin-serve`, embeds -- leave it undefined).
- `ModelPropertiesDrawer.tsx` -- Hamburger-menu drawer: model name, sim-spec fields (start/stop/dt/time units), "Download model", and -- when `onDelete` is supplied -- a `DeleteProjectButton`.
- `DeleteProjectButton.tsx` -- Low-emphasis destructive button + modal confirmation (`Dialog`) that calls `onDelete`; a rejected `onDelete` keeps the dialog open with the error message, a resolved one means the host has navigated away. Kept separate so the confirmation state lives in one small, reusable place.
- `VariableDetails.tsx` -- Variable properties/equation panel (stocks, flows, auxes)
- `ModuleDetails.tsx` -- Module properties panel: model reference selector, input wiring table, output ports, units/docs editors
- `BreadcrumbBar.tsx` -- Breadcrumb navigation: back arrow + breadcrumb trail when inside a module, hamburger menu at root
Expand All @@ -22,7 +24,7 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md).
- `arc-utils.ts` -- Arc geometry helpers (`radToDeg`, `degToRad`, arc math)
- `keyboard-shortcuts.ts` -- Keyboard shortcut handling
- `StaticDiagram.tsx` -- Static (non-interactive) diagram renderer
- `HostedWebEditor.tsx` -- Web editor wrapper
- `HostedWebEditor.tsx` -- Web editor wrapper for `src/app`: loads/saves a hosted project via the server's project HTTP API and owns `handleDelete` (DELETEs the project route, then full-navigates to the project list). Passes `onDeleteProject` to `Editor` only when not `readOnlyMode`.
- `LineChart.tsx` -- Chart visualization

### Module Logic (Functional Core)
Expand Down
9 changes: 9 additions & 0 deletions src/diagram/DeleteProjectButton.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.trigger {
justify-content: center;
width: 100%;
margin-top: 12px;
}

.errorText {
color: var(--color-error);
}
113 changes: 113 additions & 0 deletions src/diagram/DeleteProjectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

import * as React from 'react';

import Button from './components/Button';
import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from './components/Dialog';
import { DeleteIcon } from './components/icons';

import styles from './DeleteProjectButton.module.css';

interface DeleteProjectButtonProps {
/** Display name shown in the confirmation prompt. */
projectName: string;
/**
* Performs the deletion. Resolving means the caller has navigated away (so
* this component is about to unmount); rejecting surfaces the error in the
* still-open confirmation dialog so the user can retry.
*/
onDelete: () => Promise<void>;
}

interface DeleteProjectButtonState {
confirmOpen: boolean;
deleting: boolean;
error?: string;
}

function errorMessage(err: unknown): string {
if (err instanceof Error && err.message) {
return err.message;
}
const s = String(err);
return s && s !== '[object Object]' ? s : 'unable to delete project';
}

/**
* "Delete project" action: a low-emphasis destructive button that opens a
* modal confirmation before invoking `onDelete`. Kept separate from
* `ModelPropertiesDrawer` so the confirmation state lives in one small place
* and can be reused by other surfaces (e.g. a project list).
*/
export class DeleteProjectButton extends React.PureComponent<DeleteProjectButtonProps, DeleteProjectButtonState> {
state: DeleteProjectButtonState = { confirmOpen: false, deleting: false };

private openConfirm = (): void => {
this.setState({ confirmOpen: true, error: undefined });
};

private closeConfirm = (): void => {
// Don't let an outside-click / Escape dismiss the dialog mid-delete.
if (this.state.deleting) {
return;
}
this.setState({ confirmOpen: false, error: undefined });
};

private confirmDelete = async (): Promise<void> => {
if (this.state.deleting) {
return;
}
this.setState({ deleting: true, error: undefined });
try {
await this.props.onDelete();
// Success: the caller navigates away and this component unmounts. Leave
// `deleting` set so the buttons stay disabled during that brief window.
} catch (err) {
this.setState({ deleting: false, error: errorMessage(err) });
}
};

render(): React.ReactNode {
const { projectName } = this.props;
const { confirmOpen, deleting, error } = this.state;

return (
<>
<Button
className={styles.trigger}
variant="outlined"
color="error"
size="large"
startIcon={<DeleteIcon />}
onClick={this.openConfirm}
>
Delete project
</Button>
<Dialog open={confirmOpen} onClose={this.closeConfirm} aria-labelledby="delete-project-title">
<DialogTitle id="delete-project-title">Delete this project?</DialogTitle>
<DialogContent>
<DialogContentText>
This permanently deletes &ldquo;{projectName}&rdquo; and can&rsquo;t be undone.
</DialogContentText>
{error ? (
<DialogContentText className={styles.errorText}>
<b>{error}</b>
</DialogContentText>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={this.closeConfirm} disabled={deleting}>
Cancel
</Button>
<Button variant="contained" color="error" onClick={this.confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</>
);
}
}
11 changes: 11 additions & 0 deletions src/diagram/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ interface EditorPropsBase {
// (e.g. simlin-serve's EditorHost) use this to forward selection state
// to backend listeners; HostedWebEditor in src/app does not subscribe.
onSelectionChanged?: (idents: string[]) => void;
// When provided (and the editor is not read-only), the model-properties
// drawer offers a destructive "Delete project" action that calls this.
// Resolving means the host has navigated away; rejecting surfaces the
// error in the confirmation dialog. Hosts without a deletable backing
// project (the local file-backed viewer, embeds) leave this undefined.
onDeleteProject?: () => Promise<void>;
}

export type EditorProps = EditorPropsBase & ProjectInputProps;
Expand Down Expand Up @@ -1478,6 +1484,10 @@ export class Editor extends React.PureComponent<EditorProps, EditorState> {
const simSpec = project.simSpecs;
const dt = simSpec.dt.isReciprocal ? 1 / simSpec.dt.value : simSpec.dt.value;

// A read-only viewer should never see a delete affordance even if a host
// wired the callback.
const onDelete = !this.props.readOnlyMode ? this.props.onDeleteProject : undefined;

return (
<ModelPropertiesDrawer
modelName={project.name}
Expand All @@ -1492,6 +1502,7 @@ export class Editor extends React.PureComponent<EditorProps, EditorState> {
onDtChange={this.handleDtChange}
onTimeUnitsChange={this.handleTimeUnitsChange}
onDownloadXmile={this.handleDownloadXmile}
onDelete={onDelete}
/>
);
}
Expand Down
40 changes: 40 additions & 0 deletions src/diagram/HostedWebEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,45 @@ export class HostedWebEditor extends React.PureComponent<HostedWebEditorProps, H
return projectVersion;
};

handleDelete = async (): Promise<void> => {
if (this.props.readOnlyMode) return;

const base = this.getBaseURL();
const apiPath = `${base}/api/projects/${this.props.username}/${this.props.projectName}`;
const response = await fetch(apiPath, {
credentials: 'same-origin',
method: 'DELETE',
cache: 'no-cache',
});

const status = response.status;
if (!(status >= 200 && status < 400)) {
let errorMsg = `HTTP ${status} while deleting project`;
try {
const body = await response.json();
if (body && typeof body.error === 'string') {
errorMsg = body.error as string;
}
} catch {
// keep the status-bearing fallback
}
// Surface this to the in-editor confirmation dialog (which stays open
// for a retry) rather than appendModelError(): once a project loads,
// serviceErrors are no longer rendered.
throw new Error(errorMsg);
}

// Full navigation back to the project list so it refetches without the
// just-deleted project.
this.redirectToHome(`${base}/`);
};

// Extracted so tests can observe the post-delete navigation without
// assigning to jsdom's non-writable window.location.
redirectToHome(url: string): void {
window.location.assign(url);
}

async loadProject(): Promise<void> {
const base = this.getBaseURL();
const apiPath = `${base}/api/projects/${this.props.username}/${this.props.projectName}`;
Expand Down Expand Up @@ -138,6 +177,7 @@ export class HostedWebEditor extends React.PureComponent<HostedWebEditorProps, H
name={this.props.projectName}
embedded={this.props.embedded}
onSave={this.handleSave}
onDeleteProject={this.props.readOnlyMode ? undefined : this.handleDelete}
readOnlyMode={this.props.readOnlyMode}
/>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/diagram/ModelPropertiesDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Drawer from './components/Drawer';
import TextField from './components/TextField';
import { ArrowBackIcon, ClearIcon, CloudDownloadIcon } from './components/icons';

import { DeleteProjectButton } from './DeleteProjectButton';
import { ModelIcon } from './ModelIcon';

import styles from './ModelPropertiesDrawer.module.css';
Expand All @@ -28,6 +29,10 @@ interface ModelPropertiesDrawerProps {
onDtChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onTimeUnitsChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onDownloadXmile: () => void;
// When provided, a destructive "Delete project" action is shown. Hosts that
// can't (or shouldn't) delete -- read-only viewers, embeds, the local
// file-backed viewer -- simply leave this undefined.
onDelete?: () => Promise<void>;
}

export class ModelPropertiesDrawer extends React.PureComponent<ModelPropertiesDrawerProps> {
Expand Down Expand Up @@ -106,6 +111,9 @@ export class ModelPropertiesDrawer extends React.PureComponent<ModelPropertiesDr
>
Download model
</Button>
{this.props.onDelete ? (
<DeleteProjectButton projectName={modelName} onDelete={this.props.onDelete} />
) : null}
</div>
</div>
</Drawer>
Expand Down
33 changes: 33 additions & 0 deletions src/diagram/components/Button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
background-color: rgba(220, 0, 78, 0.04);
}

/* text + error */
.textError {
color: var(--color-error);
background: transparent;
}

.textError:hover {
background-color: rgba(198, 40, 40, 0.04);
}

/* contained + primary */
.containedPrimary {
color: #fff;
Expand All @@ -85,6 +95,17 @@
background-color: #9a0036;
}

/* contained + error */
.containedError {
color: #fff;
background-color: var(--color-error);
box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12);
}

.containedError:hover {
background-color: #b71c1c;
}

/* disabled states */
.disabledText {
color: rgba(0, 0, 0, 0.26);
Expand Down Expand Up @@ -122,6 +143,18 @@
background-color: rgba(220, 0, 78, 0.04);
}

/* outlined + error */
.outlinedError {
color: var(--color-error);
border: 1px solid rgba(198, 40, 40, 0.5);
background: transparent;
}

.outlinedError:hover {
border-color: var(--color-error);
background-color: rgba(198, 40, 40, 0.04);
}

/* outlined + inherit */
.outlinedInherit {
color: inherit;
Expand Down
25 changes: 19 additions & 6 deletions src/diagram/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import styles from './Button.module.css';

interface ButtonProps {
variant?: 'text' | 'contained' | 'outlined';
color?: 'primary' | 'secondary' | 'inherit';
color?: 'primary' | 'secondary' | 'error' | 'inherit';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
Expand Down Expand Up @@ -49,19 +49,32 @@ export default class Button extends React.PureComponent<ButtonProps> {
let variantColorClass: string;
let disabledClass: string | undefined;
if (variant === 'contained') {
variantColorClass = color === 'secondary' ? styles.containedSecondary : styles.containedPrimary;
variantColorClass =
color === 'secondary'
? styles.containedSecondary
: color === 'error'
? styles.containedError
: styles.containedPrimary;
disabledClass = disabled ? styles.disabledContained : undefined;
} else if (variant === 'outlined') {
variantColorClass =
color === 'secondary'
? styles.outlinedSecondary
: color === 'inherit'
? styles.outlinedInherit
: styles.outlinedPrimary;
: color === 'error'
? styles.outlinedError
: color === 'inherit'
? styles.outlinedInherit
: styles.outlinedPrimary;
disabledClass = disabled ? styles.disabledOutlined : undefined;
} else {
variantColorClass =
color === 'secondary' ? styles.textSecondary : color === 'inherit' ? styles.textInherit : styles.textPrimary;
color === 'secondary'
? styles.textSecondary
: color === 'error'
? styles.textError
: color === 'inherit'
? styles.textInherit
: styles.textPrimary;
disabledClass = disabled ? styles.disabledText : undefined;
}

Expand Down
6 changes: 6 additions & 0 deletions src/diagram/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export const EditIcon = (props: IconProps) => (
</SvgIcon>
);

export const DeleteIcon = (props: IconProps) => (
<SvgIcon {...props}>
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6zM19 4h-3.5l-1-1h-5l-1 1H5v2h14z" />
</SvgIcon>
);

export const MenuIcon = (props: IconProps) => (
<SvgIcon {...props}>
<path d="M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z" />
Expand Down
Loading
Loading