diff --git a/src/diagram/CLAUDE.md b/src/diagram/CLAUDE.md index f4e85e027..4cf0810ea 100644 --- a/src/diagram/CLAUDE.md +++ b/src/diagram/CLAUDE.md @@ -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. @@ -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` 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 @@ -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) diff --git a/src/diagram/DeleteProjectButton.module.css b/src/diagram/DeleteProjectButton.module.css new file mode 100644 index 000000000..3b7febccd --- /dev/null +++ b/src/diagram/DeleteProjectButton.module.css @@ -0,0 +1,9 @@ +.trigger { + justify-content: center; + width: 100%; + margin-top: 12px; +} + +.errorText { + color: var(--color-error); +} diff --git a/src/diagram/DeleteProjectButton.tsx b/src/diagram/DeleteProjectButton.tsx new file mode 100644 index 000000000..24c1e3ada --- /dev/null +++ b/src/diagram/DeleteProjectButton.tsx @@ -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; +} + +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 { + 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 => { + 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 ( + <> + + + Delete this project? + + + This permanently deletes “{projectName}” and can’t be undone. + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); + } +} diff --git a/src/diagram/Editor.tsx b/src/diagram/Editor.tsx index c17059384..56ec53847 100644 --- a/src/diagram/Editor.tsx +++ b/src/diagram/Editor.tsx @@ -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; } export type EditorProps = EditorPropsBase & ProjectInputProps; @@ -1478,6 +1484,10 @@ export class Editor extends React.PureComponent { 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 ( { onDtChange={this.handleDtChange} onTimeUnitsChange={this.handleTimeUnitsChange} onDownloadXmile={this.handleDownloadXmile} + onDelete={onDelete} /> ); } diff --git a/src/diagram/HostedWebEditor.tsx b/src/diagram/HostedWebEditor.tsx index 4c1b862cd..fc9d81684 100644 --- a/src/diagram/HostedWebEditor.tsx +++ b/src/diagram/HostedWebEditor.tsx @@ -97,6 +97,45 @@ export class HostedWebEditor extends React.PureComponent => { + 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 { const base = this.getBaseURL(); const apiPath = `${base}/api/projects/${this.props.username}/${this.props.projectName}`; @@ -138,6 +177,7 @@ export class HostedWebEditor extends React.PureComponent diff --git a/src/diagram/ModelPropertiesDrawer.tsx b/src/diagram/ModelPropertiesDrawer.tsx index c662d4dc4..1316059fc 100644 --- a/src/diagram/ModelPropertiesDrawer.tsx +++ b/src/diagram/ModelPropertiesDrawer.tsx @@ -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'; @@ -28,6 +29,10 @@ interface ModelPropertiesDrawerProps { onDtChange: (event: React.ChangeEvent) => void; onTimeUnitsChange: (event: React.ChangeEvent) => 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; } export class ModelPropertiesDrawer extends React.PureComponent { @@ -106,6 +111,9 @@ export class ModelPropertiesDrawer extends React.PureComponent Download model + {this.props.onDelete ? ( + + ) : null} diff --git a/src/diagram/components/Button.module.css b/src/diagram/components/Button.module.css index 9a4e33254..ec906346c 100644 --- a/src/diagram/components/Button.module.css +++ b/src/diagram/components/Button.module.css @@ -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; @@ -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); @@ -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; diff --git a/src/diagram/components/Button.tsx b/src/diagram/components/Button.tsx index da06257a1..77751953e 100644 --- a/src/diagram/components/Button.tsx +++ b/src/diagram/components/Button.tsx @@ -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) => void; @@ -49,19 +49,32 @@ export default class Button extends React.PureComponent { 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; } diff --git a/src/diagram/components/icons.tsx b/src/diagram/components/icons.tsx index 52fbb74af..e6ae1ad65 100644 --- a/src/diagram/components/icons.tsx +++ b/src/diagram/components/icons.tsx @@ -22,6 +22,12 @@ export const EditIcon = (props: IconProps) => ( ); +export const DeleteIcon = (props: IconProps) => ( + + + +); + export const MenuIcon = (props: IconProps) => ( diff --git a/src/diagram/tests/button.test.tsx b/src/diagram/tests/button.test.tsx index e505b92d3..88d53b368 100644 --- a/src/diagram/tests/button.test.tsx +++ b/src/diagram/tests/button.test.tsx @@ -69,6 +69,29 @@ describe('Button', () => { expect(button.className).toContain('textPrimary'); }); + test('applies contained error classes', () => { + render( + , + ); + expect(screen.getByRole('button').className).toContain('containedError'); + }); + + test('applies outlined error classes', () => { + render( + , + ); + expect(screen.getByRole('button').className).toContain('outlinedError'); + }); + + test('applies text error classes', () => { + render(); + expect(screen.getByRole('button').className).toContain('textError'); + }); + test('applies outlined primary classes', () => { render(