From 9bc2bcb520ef3fe0066f09a300f54b09b267d511 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:14:33 +0000 Subject: [PATCH 01/33] docs: UI/UX improvement proposals + four interactive mockups Prioritized proposals (guided readiness rail, context strip, compute cost estimator, preflight checklist, live run monitor) grounded in the current Job.jsx / ComputeTab / WorkflowTab code, with self-contained HTML mockups in mockups/ - no build step needed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- UIUX_IMPROVEMENTS.md | 107 ++++++++ mockups/01-guided-designer.html | 424 +++++++++++++++++++++++++++++++ mockups/02-compute-cost.html | 315 +++++++++++++++++++++++ mockups/03-preflight-submit.html | 283 +++++++++++++++++++++ mockups/04-run-monitor.html | 346 +++++++++++++++++++++++++ mockups/README.md | 16 ++ 6 files changed, 1491 insertions(+) create mode 100644 UIUX_IMPROVEMENTS.md create mode 100644 mockups/01-guided-designer.html create mode 100644 mockups/02-compute-cost.html create mode 100644 mockups/03-preflight-submit.html create mode 100644 mockups/04-run-monitor.html create mode 100644 mockups/README.md diff --git a/UIUX_IMPROVEMENTS.md b/UIUX_IMPROVEMENTS.md new file mode 100644 index 0000000..eed3d5f --- /dev/null +++ b/UIUX_IMPROVEMENTS.md @@ -0,0 +1,107 @@ +# Job Designer — UI/UX improvement proposals + +Companion to the Materials Designer exercise: prioritized interface proposals for the Job +Designer, grounded in the current code, with interactive mockups in [`mockups/`](mockups/). + +The designer today is a five-tab shell (`src/components/Job.jsx`) around three packages: +the workflow editor (`@mat3ra/workflow-designer`), the compute form (`Compute` from +`@mat3ra/ive`), and results/files views (`@mat3ra/jove`). Most of what a user must *do* to +create a job — pick materials, pick a workflow, configure compute, submit — hides behind +the "Select Job Actions" dropdown built in `Job.jsx#getDefaultActions`, and the numbered +tabs (`1. MATERIALS / 2. WORKFLOW / 3. COMPUTE` from `TAB_NAVIGATION_CONFIG` in +`@mat3ra/jode`) imply a sequence without tracking progress through it. + +## Observations (current state) + +1. **The path is hidden.** Select materials / workflow / parent / dataset and Submit all + live in one dropdown menu; nothing on screen says what a new job still needs before it + can run. Tabs are numbered like steps but carry no completion state. +2. **No cross-tab context.** On the Compute tab there is no trace of which material or + workflow is selected; on the Workflow tab, no trace of compute. Every check requires a + tab switch. +3. **Compute answers no questions.** The form opens with four red "The field is required" + errors before the user has touched anything, cluster/queue are bare selects, cluster + status is an external link, and nothing estimates core-hours, cost, or queue wait. +4. **Workflow tab noise.** A second "Compute" sub-tab inside the Workflow tab duplicates + the top-level tab; raw UUIDs are printed on every subworkflow card and flowchart node; + "idle" status chips decorate a job that was never submitted; the flowchart pane stays + light-themed inside the dark shell. +5. **Submit is a leap of faith.** No preflight: nothing validates compute against cluster + limits or shows the cost before the job leaves. After submit the user lands on the same + editing view; Results/Files tabs appear only later and are passive lists. +6. **Materials are a lone canvas.** The Materials tab is a full-bleed 3D viewer with no + metadata panel; for multi-material jobs the set switcher hides inside the Workflow tab, + and the "runs N times" consequence of a materials set is never stated. + +## Proposals + +### A — One glance, one path +- **A1 · Readiness rail.** Replace the numbered full-width tabs + actions dropdown with a + left rail of lifecycle steps — Material, Workflow, Compute, Review & Submit — each + showing its current selection summary and state (complete / needs attention / empty). + The three "Select …" dialogs become "Change" affordances on their steps. +- **A2 · Context strip.** A persistent strip under the header with chips for material, + workflow, compute and estimated cost — visible from every step, each chip a shortcut. +- **A3 · First-class Submit.** Submit is a primary header button with live preflight + state (disabled explains *what's missing*), replaced by Terminate while running. + +### B — Compute that answers "what will this run, and what will it cost?" +- **B1 · Cluster cards.** Replace bare selects with selectable cards: hardware summary, + per-core-hour price, live queue-wait badge (inline; kills the "See cluster status" link). +- **B2 · Live estimate.** Side panel derives core-hours = nodes × cores × walltime, price, + queue ETA, and quota impact as the form changes; flags requests that exceed queue limits. +- **B3 · Presets.** Debug / Standard / Production (and "same as last job") one-click fills. +- **B4 · Progressive validation.** Validate on interaction and at preflight — never render + a screen of red required-field errors on first paint. + +### C — Review & submit with confidence +- **C1 · Preflight checklist.** Submit opens a check run — material set, workflow + parameters render, compute within cluster/queue limits, cost within budget — with + pass / warn / fail rows; fails deep-link to the offending step, warns are acknowledgeable. +- **C2 · Post-submit hand-off.** Successful submit transitions the designer into monitor + mode (F1) instead of leaving a stale editor open. + +### D — Workflow tab clarity +- **D1 · One Compute.** Drop the duplicated "Compute" sub-tab inside the Workflow tab. +- **D2 · Humane metadata.** UUIDs move behind a copy-id affordance; status chips appear + only once a job has been submitted. +- **D3 · Unit inspector.** Clicking a flowchart node opens a right drawer with that unit's + important settings, replacing the Overview / Important settings / Detailed view sub-tab + bounce. +- **D4 · Theme parity.** Token-driven theming for the flowchart pane so it follows the + shell's theme. + +### E — Materials in context +- **E1 · Materials tray.** Chips for the materials set above the viewer with add / remove / + switch inline, and explicit "this job runs N times — once per material" copy. +- **E2 · Metadata panel.** Formula, lattice, atom count, source id beside the 3D viewer. + +### F — The job lives after Submit +- **F1 · Run monitor.** The post-submit view becomes live: per-unit timeline with + statuses and durations, streaming log tail, convergence chart, files appearing as + produced, results summary on finish. +- **F2 · Lifecycle header.** Draft → Queued → Running → Finished timeline with timestamps + in the header, replacing the lone status-colored icon (`iconCls: text-${job.statusCls}`). + +## Mockups + +| # | File | Covers | +|---|------|--------| +| 01 | [`mockups/01-guided-designer.html`](mockups/01-guided-designer.html) | A1 A2 A3 · D2 · E1 E2 | +| 02 | [`mockups/02-compute-cost.html`](mockups/02-compute-cost.html) | B1 B2 B3 B4 | +| 03 | [`mockups/03-preflight-submit.html`](mockups/03-preflight-submit.html) | C1 C2 | +| 04 | [`mockups/04-run-monitor.html`](mockups/04-run-monitor.html) | F1 F2 · C2 | + +Each mockup is a self-contained HTML file (no build step, no network) — open directly in a +browser. + +## Rollout sketch + +| Phase | Scope | Proposals | Mostly lands in | +|-------|-------|-----------|-----------------| +| 1 — de-noise | Low-risk cleanups inside current layout | B4 · D1 · D2 · A3 | job-designer, `@mat3ra/ive`, `@mat3ra/workflow-designer` | +| 2 — the guided designer | Layout change: rail + context strip + compute redesign + preflight | A1 · A2 · B1–B3 · C1 · E1 · E2 | job-designer (`Job.jsx`), `@mat3ra/ive`, `@mat3ra/cove` | +| 3 — the living job | Monitoring + inspector + theming | F1 · F2 · C2 · D3 · D4 | `@mat3ra/jove`, `@mat3ra/workflow-designer` | + +Phase 1 is deliberately shippable without design sign-off on the new layout; Phase 2 is +where the designer stops being a filing cabinet; Phase 3 closes the loop after submission. diff --git a/mockups/01-guided-designer.html b/mockups/01-guided-designer.html new file mode 100644 index 0000000..e31a9e3 --- /dev/null +++ b/mockups/01-guided-designer.html @@ -0,0 +1,424 @@ + + + + + +Mockup 01 — Guided designer + + + + +
+
+
+
+
Molecular Dynamics — Si, mp-149
+
Demo Project · created just now
+
+
+ DRAFT + All changes saved + compute not configured + + +
+ +
+ + + + +
+ +
+ + +
+ +
+

Material

+

The structure(s) this job will run on. Add more to turn this into a batch.

+
+ Si₂ · mp-149 + + 1 material → the workflow runs once. +
+
+
+ + + + + + + + + + + + drag to rotate · scroll to zoom +
+
+ + + + + + +
FormulaSi₂
LatticeFCC · a = 3.867 Å
Space groupFd-3m (227)
Atoms in cell2
Sourcemp-149
+
+
+
+ + +
+

Workflow

+

What will run, in order. Open a subworkflow to tune its unit parameters.

+
+
+ I + CP-MD
Quantum ESPRESSO · cp.x
+ 2 units + +
+
+ II + DeePMD
DeePMD-kit · dp train
+ 3 units + +
+
+ 5 units total · no status chips until the job runs + + + +
+
+
+ + +
+

Compute

+

Pick a preset to start — everything stays editable. Full estimator in mockup 02.

+
+ + + +
+
No compute selected yet — choose a preset above.
+ +
+ + +
+

Review & submit

+

Preflight runs these checks again at submit. Full flow in mockup 03.

+
+
Material set — Si₂ (mp-149)
+
Workflow renders — 5 units, no template errors
+
Compute configured
+
Within monthly core-hour budget
+
+
+ + compute not configured +
+
+
+
+ +
Submitted. Job is queued on cluster-007 — opening monitor…
+ + + + diff --git a/mockups/02-compute-cost.html b/mockups/02-compute-cost.html new file mode 100644 index 0000000..9fbf060 --- /dev/null +++ b/mockups/02-compute-cost.html @@ -0,0 +1,315 @@ + + + + + +Mockup 02 — Compute & cost + + + + +
+

Compute

+ Molecular Dynamics — DeePMD · Si₂ (mp-149) · Demo Project +
+ +
+
+
+

Cluster — live queue status inline

+
+ + + +
+
+ +
+

Presets

+
+ + + + +
+
+ +
+

Resources

+
+
+ +
+ + 1 + +
+
up to 16 per job
+
+
+ +
+ + 16 + +
+
128 available on cluster-007
+
+
+ +
+ + 4h + +
+
queue OR allows up to 12 h
+
+
+ + + +
+
+

Nothing is marked red until you interact — validation runs on change and again at preflight.

+
+
+ + +
+ + + + diff --git a/mockups/03-preflight-submit.html b/mockups/03-preflight-submit.html new file mode 100644 index 0000000..07583e9 --- /dev/null +++ b/mockups/03-preflight-submit.html @@ -0,0 +1,283 @@ + + + + + +Mockup 03 — Preflight & submit + + + + + + + + + + + diff --git a/mockups/04-run-monitor.html b/mockups/04-run-monitor.html new file mode 100644 index 0000000..d4b505e --- /dev/null +++ b/mockups/04-run-monitor.html @@ -0,0 +1,346 @@ + + + + + +Mockup 04 — Run monitor + + + + +
+
+

Molecular Dynamics — Si, mp-149

+ elapsed 00:00 + + + +
+
+ Draft 14:02 + + Queued 14:05 + + Running 14:07 + + Finished +
+
+ +
+ +
+

Units

click to filter the log
+
+
3 files · stdout updated just now
+
+ + +
+
+
Total energy
−19.2634 eV
+
Pressure
0.42 kbar
+
Wall used
of 4 h
+
+
+ +
+
+

Total energy convergence

+ cp-md + + ΔE — +
+
+ +
+
+
+ +
+

Log

all units
+
+
+
+
+ + + + diff --git a/mockups/README.md b/mockups/README.md new file mode 100644 index 0000000..c0298a0 --- /dev/null +++ b/mockups/README.md @@ -0,0 +1,16 @@ +# Job Designer — UI/UX mockups + +Interactive, self-contained HTML mockups for the proposals in +[`../UIUX_IMPROVEMENTS.md`](../UIUX_IMPROVEMENTS.md). No build step, no network — open any +file directly in a browser. + +| # | File | Shows | Try | +|---|------|-------|-----| +| 01 | `01-guided-designer.html` | Readiness rail, context strip, first-class Submit (A1–A3), materials tray (E1–E2), de-noised workflow list (D2) | Open the **Compute** step and pick a preset — the rail, chips and Submit react. Then submit. | +| 02 | `02-compute-cost.html` | Cluster cards, presets, live cost/quota estimate, progressive validation (B1–B4) | Switch clusters, push cores/walltime past the limits, watch the estimate and flags. | +| 03 | `03-preflight-submit.html` | Preflight checklist with pass / warn / fail, deep-link fixes, submit hand-off (C1–C2) | Let the checks run, click *Fix → set 12 h*, acknowledge the warning, submit. | +| 04 | `04-run-monitor.html` | Live run monitor: lifecycle timeline, per-unit progress, streaming log, convergence chart (F1–F2) | Watch the simulated run; click a unit to filter the log; hover the chart; replay. | + +The four files share one set of design tokens (dark shell matching the standalone demo, +`#8b6cf0` accent, reserved status hues always paired with an icon + label) so they read as +one product. From fde0ca13416cd1944773e3acbaafdc520afe9fa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:43:25 +0000 Subject: [PATCH 02/33] docs: adopt mat3ra/agents conventions + plan/ folder with guided-designer plan - AGENTS.md copied verbatim from mat3ra/agents@c75a1b8 - plan/README.md from mat3ra/agents templates/plan/README.md - plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md: three-phase implementation plan for the UI/UX proposals in UIUX_IMPROVEMENTS.md Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- AGENTS.md | 232 ++++++++++++++++ plan/README.md | 42 +++ ...X-574-Job-Designer-Guided-Designer-Plan.md | 250 ++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 AGENTS.md create mode 100644 plan/README.md create mode 100644 plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..72e3ab1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,232 @@ +# AGENTS.md + +This document describes generic architecture and conventions for AI coding agents. + +**Scope:** these conventions are written from a C++ / numerical-simulation codebase (plane-wave +DFT, Q3/ESSE) and apply as-is there. For other stacks, apply the same principles by analogy — the +naming rules and OOP antipatterns hold across languages, but tooling-specific items (GTest, ESSE +schema `$ref`s) don't. Skip a rule silently if it names a tool or file layout that doesn't exist +in the repo you're reviewing. + +**Precedence:** this file owns *code conventions*. `AGENTS-code-review-tb.md`, where present, +owns *review process, severity, and tone*, and cites the rules here by name rather than restating +them. + +## 1. Conventions + +### 1.1. Design Patterns + +- **Factory**: when multiple implementations are possible - e.g. multiple exchange-correlation functionals, or multiple k-point samplers. +- **Object-oriented design**: define abstract interfaces for components that have multiple implementations - e.g. Method → PseudopotentialMethod, PlaneWaveMethod, Model → DFTModel, HFModel, etc. + +### 1.2. OOP Guidelines & Antipatterns + +**Prefer polymorphism over type-checking chains.** Instead of: + +```cpp +// ❌ ANTIPATTERN: long if-chain checking object type +if (functional.is_lda()) { + compute_lda_energy(...); +} else if (functional.is_gga()) { + compute_gga_energy(...); +} else if (functional.is_meta_gga()) { + compute_meta_gga_energy(...); +} +``` + +Use: + +```cpp +// ✅ CORRECT: polymorphic dispatch via virtual method +Real energy = functional.compute_energy(density); +``` + +**Key principles:** + +- **Single Responsibility**: each class does one thing. If a class has methods for reading, computing, and writing, split it. +- **Open/Closed**: add new behavior by adding new classes, not by adding `if` branches to existing code. +- **Interface Segregation**: keep interfaces small. Don't force implementors to provide methods they don't need. +- **No `is_xxx()` type queries**: if you need `is_ultrasoft()`, `is_paw()`, `is_norm_conserving()`, your design likely needs a virtual method instead. +- **Favor composition over inheritance** for combining behaviors: use mixins (e.g., `BinarySerializableMixin`) rather than deep inheritance hierarchies. +- **Use factories** to create the right subclass from runtime configuration (e.g., `create_diagonalizer("davidson")`). + +### 1.3. Logging + +- All output via logger +- Allow log level to be set via command line argument (critical, error, warn, info, debug, trace). Default is error. +- Only rank 0 outputs (MPI-aware initialization) +- No print statement in the log + +### 1.4. Testing + +- Unit tests: `tests/unit/` (GTest) +- Integration tests: `tests/integration/` + +### 1.5. Comment Style + +- **Multiline docstrings** must have `/**` on its own line followed by the comment body: + +```cpp +// ✅ CORRECT +/** + * Compute the angular phase factor (-i)^l. + */ + +// ❌ INCORRECT +/** Compute the angular phase factor (-i)^l. + */ +``` + +- Use `///` for single-line doc comments. +- Use `//` for inline implementation comments. +- Never use bare `/* ... */` for documentation; use `/** ... */`. + +### 1.6. Linter + +Use linting for autoformatting the codebase. Consider language-specific tools and/or prettier. + +### 1.7. Pre-commit + +Use pre-commit to run linters and formatters automatically. + +### 1.8. GitHub Actions + +Use GitHub Actions to run tests and linters automatically. + +## 2. !!! IMPORTANT !!!: Code Editing & Development HARD RULES + +### 2.1. HARD RULE 1: Never commit without explicit ask from user + +NEVER commit changes using `git commit` without the user's explicit ask. Leave files in the working directory for the user to review. + +### 2.2. HARD RULE 2: use `/agents/workdir/` for ALL scratch files. + +NEVER create any throwaway files at the top level of the project directory (``). The top level of `` must remain clean and contain only tracked project files. All throw-away scripts — debug helpers, patch scripts, test snippets, one-off analysis scripts — MUST go in `/agents/workdir/tmp/`. Create that directory if it does not exist. Examples of files that belong in `/agents/workdir/tmp/`: `debug_*.py`, `fix_*.py`, `patch_*.py`, `print_*.py`, `test_*.py` / `test_*.cpp` that are not formal tests in `tests/`, any other ephemeral script written to inspect or patch source code. Any potentially reusable agent artifacts should be either in `/agents/workdir/reusable` (if they're intended to be used in the current project only) or in the repository's top-level `plan/` folder (see section 6) if they're plan or context documents intended to persist. NO EXCEPTIONS. + +### 2.3. HARD RULE 3: Always setup and use a virtual environment + +(`venv`) when working with Python. Do NOT install Python packages globally. Use pyenv to select python version(s). Create venv in the agents workdir directory as explained in the next item + +## 3. HARD RULE 4: names with no abbreviations, Snake for Py, Camel for JS/TS, classnames + +Variables, functions, methods, field names, class names, type names, file names. Always use full, descriptive names. For example: + +- ❌ `nkp`, `nbnd`, `nspin`, `npw`, `ik`, `ib`, `ig`, `ia`, `et`, `pw`, `ppset` +- ✅ `number_of_kpoints`, `number_of_bands`, `number_of_spin_components`, `number_of_plane_waves`, `kpoint_index`, `band_index`, `g_index`, `atom_index`, `eigenvalues`, `planewave_basis`, `pseudopotential_set` +- ❌ `Vec3`, `Mat3`, `IVec3` +- ✅ `Vector3D`, `Matrix3x3`, `IntegerVector3D` + +Also: + +- Use **snake_case** for variables, functions, and file names. +- Use **PascalCase** for classes and structs. +- Member variables use trailing underscore: `planewave_basis_`, `number_of_bands_`. +- General rule: if a name looks abbreviated, spell it out. Exceptions could be made for complex physical and mathematical expressions where the abbreviation is widely known and used for compacting the representation. However, even in these cases, try to spell it out. Make sure to make it obvious from the context what the abbreviation means. + +## 4. Other + +### 4.1. JSON Formatting + +- **HARD RULE**: JSON schemas MUST follow ESSE formatting conventions: + - **4-space indentation** (matching ESSE `.prettierrc`) + - **100 character print width** + - **Double quotes** only (standard JSON) + - **Trailing newline** at end of file + - **Bracket spacing** enabled (e.g., `{ "key": "value" }`) + - All Q3 result schemas must `$ref` their corresponding ESSE schema in `schemas/esse/schema/` + +### 4.2. Code Reviews + +- **HARD RULE**: All PR reviews must be saved locally according to the following strict directory and naming structure: + - Directory: `reviews///pr-/` + - Filename: `comments-.md` (using the 7-character short hash of the latest commit in the PR) + - Example: `reviews/mat3ra/q3/pr-10/comments-b307df4.md` + +## 5. Concrete Review Examples + +When conducting PR reviews, look for these specific architectural and hygiene violations to flag. + +### 5.1. Magic Numbers for Tolerance + +**❌ ANTIPATTERN (Flag this):** +```cpp +// QE uses a looser convergence threshold for empty (unoccupied) bands: +int number_of_occupied_bands = quantum_system.number_of_occupied_bands_per_spin(); +Real empty_band_tolerance = std::max(5.0 * tolerance_, 1.0e-5); +``` + +**✅ CORRECT:** +The `5.0` multiplier and `1.0e-5` lower bound should be defined centrally. +```cpp +Real empty_band_tolerance = std::max( + math::tolerances::empty_band_relaxation_factor * tolerance_, + math::tolerances::empty_band_relaxation_floor +); +``` + +### 5.2. Global Namespace Pollution + +**❌ ANTIPATTERN (Flag this):** +```cpp +extern "C" { +/** + * ZGEMM: Complex double-precision general matrix-matrix multiply. + * C := alpha * op(A) * op(B) + beta * C + */ +void zgemm_(const char* transa, ...); +} +``` + +**✅ CORRECT:** +Do not introduce bare C-style wrappers into the global namespace. Wrap them in a class or at least an inner namespace. +```cpp +namespace q3::blas { + extern "C" { + void zgemm_(const char* transa, ...); + } +} +``` + +### 5.3. Loop Duplication & DRY Violations + +**❌ ANTIPATTERN (Flag this):** +```cpp +switch (degree) { + case 0: + for (int index = 0; index < mesh_size; index++) { + Real argument = q_value * radial_grid[index]; + if (std::abs(argument) < math::tolerances::bessel_series_expansion_threshold) { ... } + } + break; + case 1: + for (int index = 0; index < mesh_size; index++) { + Real argument = q_value * radial_grid[index]; + if (std::abs(argument) < math::tolerances::bessel_series_expansion_threshold) { ... } + } + break; +} +``` + +**✅ CORRECT:** +While hoisting the switch outside the loop is good for performance, the repetitive boundary checks inside each loop violate DRY. Extract the small-argument asymptotic expansions into inline helper functions to clean this up. + +## 6. Plan Folder (`plan/`) + +Design documents and durable agent context live in a top-level `plan/` folder, filed by where +the work has got to. The folder a document sits in is the claim being made about it, so moving +it is part of doing the work — not bookkeeping to be done later: + +- `plan/upcoming/` — agreed direction, not built yet. Safe to change freely; nothing depends on it. +- `plan/review/` — built and on a branch, not yet proven. Waiting on CI, a PR, or a deploy. +- `plan/implemented/` — shipped. Kept as the record of why the code looks the way it does. On the + way in, add a `## Status` section at the top recording what shipped, divergences from the plan, + and what remains open (real open items also get an entry in `upcoming/`). +- `plan/context/` — reference material that is not a plan: investigations, measurements, + background, and context dumps written to retain state when switching models, machines, or + sessions. + +Never edit a document in `implemented/` to match the code — rewriting history loses the reason a +decision was made, which is the only thing the document is still good for; correct it with a +`## Status` note instead. Name documents `-.md`, e.g. +`SOF-8010-Containerized-Venv-Plan.md`. The canonical `plan/README.md` to copy when introducing +the folder to a repository lives in `mat3ra/agents` under `templates/plan/README.md`. diff --git a/plan/README.md b/plan/README.md new file mode 100644 index 0000000..2769b00 --- /dev/null +++ b/plan/README.md @@ -0,0 +1,42 @@ +# plan/ + +Design documents for work on this repo, filed by where the work has got to. The folder a document +sits in is the claim being made about it, so moving it is part of doing the work — not bookkeeping +to be done later. + +| folder | what is in it | +| -------------- | --------------------------------------------------------------------------------- | +| `upcoming/` | Agreed direction, not built yet. Safe to change freely; nothing depends on it. | +| `review/` | Built and on a branch, not yet proven. Waiting on a Jenkins run, a PR, or a deploy. | +| `implemented/` | Shipped. Kept as the record of why the code looks the way it does. | +| `context/` | Reference material that is not a plan — investigations, measurements, background. | + +## Working with these + +**A plan moves when its status changes, and it does not move silently.** On the way into +`implemented/`, add a `## Status` section at the top saying what actually shipped. That matters +more than it sounds: a plan is written before the work and is usually wrong somewhere, so a +document filed under `implemented/` without that section reads as "the code does this", which is a +claim nobody checked. + +Record three things there: + +- **What shipped** — one or two lines, and where the code lives. +- **Divergences** — where the built thing differs from the plan, and why. This is the part that + earns the document its place; a plan that matched reality exactly would not need it. +- **Still open** — anything the plan proposed that was not done. If it is real work, it also gets + an entry in `upcoming/`, because nobody goes looking for open items inside a file named + "implemented". + +**Do not edit a plan in `implemented/` to match the code.** Rewriting history loses the reason a +decision was made, which is the only thing the document is still good for. Correct it with a +`## Status` note instead. + +**A plan that has been superseded outright** stays in `implemented/` if its work shipped in some +other form — say so under Divergences. Only delete one if it was never acted on at all, and then +say so in the commit message. + +## Naming + +`-.md`, e.g. `SOF-8010-Containerized-Venv-Plan.md`. One ticket can have +several documents; keep them in the same folder only while they share a status. diff --git a/plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md new file mode 100644 index 0000000..7fe35d8 --- /dev/null +++ b/plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md @@ -0,0 +1,250 @@ +# UX-574 — Job Designer: Guided Designer Implementation Plan + +**Ticket:** [UX-574](https://mat3ra.atlassian.net/browse/UX-574) (UI/UX Platform Review — August +2026) is the umbrella; each phase below should get its own SOF implementation ticket when picked +up (numbers to be filed). +**Source proposals:** [`UIUX_IMPROVEMENTS.md`](../../UIUX_IMPROVEMENTS.md) (proposals A1–F2) and +the interactive mockups in [`mockups/`](../../mockups/). +**Branch of record for the brainstorm:** `claude/jd-ui-ux-improvements-99w9fg`. +**Prior art:** SOF-7978 extracted the designer into standalone packages; SOF-7991 tracks the +dependency-injection cleanup this plan must not regress. + +## Summary + +Turn the Job Designer from a five-tab filing cabinet into a guided flow: a readiness rail that +shows what a job still needs, a compute step that answers "what will this cost", a preflight +check at submit, and a live monitor after it. Work is split into three phases so that phase 1 +ships inside the current layout with no design sign-off, phase 2 introduces the new layout, and +phase 3 closes the post-submission loop. + +## Current state (grounding) + +The designer shell is `src/components/Job.jsx` (class component, mixins from `mixwith`): + +- Tabs come from `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) rendered by cove's `TabsMenu` + (`variant="fullWidth" centered`); conditional visibility via `conditionalTabsMap`. +- All creation actions live in the "Select Job Actions" dropdown built by + `Job.jsx#getDefaultActions` (select materials / workflow / parent / dataset, Submit, + Terminate). +- Tab content wraps injected packages: + `ComputeTab` → `Compute` from `@mat3ra/ive`; `WorkflowTab` → `Workflow` from + `@mat3ra/workflow-designer`; `ResultsTab` from `@mat3ra/jove`; `MaterialTab` renders an + injectable `MaterialViewerComponent`; `FilesTab` renders an injected + `FilesExplorerContainer`. +- The webapp swaps in its own header and dialogs through `setDependencies()` + (`src/setDependencies.ts`) and `JobDesignerContext`; the standalone demo + (`src/standalone/index.tsx`, `npm run dev`, port 3003) exercises the package-native + fallbacks with standata workflows/materials. + +Pain points, verified in the running standalone app (screenshots in the *Job Designer Next* +artifact): the creation path is hidden in the dropdown; numbered tabs carry no progress or +cross-tab context; the compute form shows four required-field errors on first paint and no cost +estimate; the workflow tab duplicates a "Compute" sub-tab, prints UUIDs and "idle" chips at +design time, and renders a light flowchart in the dark shell; there is no preflight at submit +and no live view after it. + +## Goals + +1. A new job is created left-to-right with visible progress; nothing required hides in a menu. +2. Compute answers cost, limits, and queue wait before submission. +3. Submit runs visible checks; failures deep-link to the fix. +4. A submitted job has a live monitor; the designer stops pretending the job is still a draft. +5. The `setDependencies()` / `JobDesignerContext` injection seam survives every change — the + webapp must keep working with its own header, dialogs, and file explorer. + +## Non-goals + +- No redesign of the workflow editing experience itself (unit graph editing, subworkflow + composition) — that is UX-500 territory. +- No changes to job submission backend contracts; preflight consumes existing data. +- No visual rebrand: reuse the platform theme; mockups' dark palette is illustrative only. + +## Phase 1 — De-noise (proposals B4, D1, D2, A3) + +Low-risk changes inside the current layout. Ship as one PR train; each item independently +revertable. + +### 1.1 Progressive validation in the compute form (B4) — `@mat3ra/ive` + +- `Compute` renders required-field errors only for touched fields (track touched state per + field) or after an explicit validate call (used by preflight later, exposed as an imperative + `validate()` ref or a `showAllErrors` prop). +- Acceptance: opening the Compute tab of a fresh job shows zero red errors; leaving a required + field empty after focusing it shows exactly one; `showAllErrors` restores today's behavior. + +### 1.2 Remove the duplicated Compute sub-tab (D1) — `@mat3ra/workflow-designer` + +- The workflow pane's internal tab strip (Overview / Important settings / Detailed view / + Compute) drops the Compute entry when the host renders its own compute surface. Add a + `hideComputeSubTab` (default false for backward compatibility) prop; job-designer passes true. +- Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. + +### 1.3 Humane metadata (D2) — `@mat3ra/workflow-designer` + job-designer + +- UUIDs on subworkflow cards and flowchart nodes move behind a copy-id icon with tooltip; + status chips ("idle") render only when the job has been submitted (`job.isInInitialStatus` + gates them — job-designer passes the flag; the workflow pane already receives + `adjustable={job.isInInitialStatus}` so thread one more boolean, e.g. `showUnitStatus`). +- Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. + +### 1.4 First-class Submit in the header (A3) — job-designer + `@mat3ra/cove` + +- Submit leaves `getDefaultActions()` and becomes a primary header button next to Save + (rendered in both header paths: the injected `EntityHeaderComponent` and the standalone + `EntityHeader` fallback). Disabled state carries a reason string ("compute not configured"); + Terminate replaces it in running states. The dropdown keeps only select-parent and + import-style power actions. +- Mind the known cove `ButtonMultiSelect` mount-snapshot behavior (see the long note in + `Job.jsx#getSaveBtnProps`): read state at click time, never capture the entity at render. +- Acceptance: a draft job with material+workflow+compute set can be submitted in one click from + any tab; the disabled button explains what is missing; webapp header parity is preserved. + +## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) + +The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, +`03-preflight-submit.html`. + +### 2.1 Readiness rail replaces numbered tabs (A1) — job-designer, `@mat3ra/jode` + +- New `JobReadinessRail` component (job-designer) renders lifecycle steps — Material, Workflow, + Compute, Review & Submit (+ Dataset when `workflow.isUsingDataset`; Results, Files after + submission) — each with state (complete / needs attention / empty) and a one-line selection + summary. It replaces `TabsMenu` as the designer's navigation; `currentTab` state machine in + `Job.jsx` stays, only the navigation surface changes. +- Step state derives from a new pure selector module (proposed `src/jobReadiness.ts`): + `getJobReadiness(job, materials) → { steps: [{ id, state, summary }], isSubmittable, + blockingReasons }`. Unit-test this module heavily; it also drives the Submit button and + preflight. +- `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) grows optional per-tab step metadata (order, label) + so webapp and standalone agree on the sequence. +- The three "Select …" dialogs stay as they are, but open from "Change" affordances on their + steps. +- Layout: rail left (fixed ~260 px, collapses to horizontal stepper under 760 px), content + right. Keep the DOM of tab panels unchanged where possible so Cypress selectors survive. +- Acceptance: a new user can create and submit a job without opening any dropdown; deep links + via `getRouteQueryTab` still land on the right step. + +### 2.2 Context strip (A2) — job-designer + +- New `JobContextStrip` under the header: chips for material (formula · source), workflow + (name · subworkflow/unit counts), compute (cluster · nodes×cores · walltime), estimate + (core-hours ≈ cost). Chips navigate to their step; incomplete chips render in the attention + style. Data comes from the same `getJobReadiness` selector plus the estimate helper (2.3). +- Acceptance: on every step, the other selections stay visible; clicking a chip switches step. + +### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` + +- Cluster picker becomes selectable cards (name, hardware summary, per-core-hour price, queue + wait badge) fed by the existing `clusters` prop; job-designer passes an optional + `clusterMetadata` enrichment (pricing, limits, queue wait) injected by the webapp through + `setDependencies()` — standalone falls back to static demo data. +- New `ComputeEstimatePanel`: core-hours = nodes × cores × walltime, price, queue ETA, monthly + quota meter. Pure function `estimateComputeCost(computeConfiguration, clusterMetadata)` + lives beside the panel and is unit-tested. +- Presets row (Debug / Standard / Production / same-as-last-job) writes through the normal + `onUpdate(compute)` path so undo/save semantics are untouched. +- Validation limits (max nodes, cores per node, queue walltime caps) come from + `clusterMetadata`; violations render inline (not red-on-first-paint — 1.1's touched logic). +- Acceptance: changing any field updates the estimate synchronously; exceeding a limit flags + the field and the estimate panel; presets fill the form in one click; webapp data path and + standalone fallback both render. + +### 2.4 Preflight at submit (C1) — job-designer (+ webapp data) + +- New `PreflightDialog` opened by Submit: runs ordered checks — material set, workflow renders + (`job.render()` succeeds / template errors empty), compute within `clusterMetadata` limits, + estimated cost vs. remaining quota. Each check row: pass / warn / fail; fails deep-link to + the owning step; warns require acknowledge. Submit proceeds only with zero fails and all + warns acknowledged, then calls the existing `onSubmit` prop. +- Check implementations live in `src/preflight/` as pure async functions + `runPreflightChecks(job, materials, clusterMetadata, quota) → PreflightReport`, injectable so + the webapp can add checks (e.g. balance) via `setDependencies()`. +- Acceptance: submitting an incomplete job is impossible through the UI; every fail row's + action lands on the field that fixes it; checks are unit-tested including the warn/ack flow. + +### 2.5 Materials tray and metadata (E1, E2) — job-designer (+ viewer package) + +- `MaterialTab` gains a chips tray above the viewer (add / remove / switch — reusing + `onUpdateIndex`, `onMaterialRemove`, `openAddMaterialsDialog`) and the explicit copy + "N materials → the workflow runs N times". Multi-material switching stops hiding inside the + Workflow tab (the workflow pane's switcher stays for parity but the tray is the primary + affordance). +- Metadata side panel (formula, lattice, space group, atom count, source id) rendered from the + `Material` model next to the injected viewer; the viewer component API is unchanged. +- Acceptance: adding a second material updates the tray, the batch copy, and the context strip; + removing the active material selects a sane neighbor. + +## Phase 3 — The living job (proposals F1, F2, C2, D3, D4) + +Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer wires props. + +### 3.1 Lifecycle header (F2) — `@mat3ra/cove` + job-designer + +- Replace the status-colored icon (`iconCls: text-${job.statusCls}`) with a lifecycle timeline + chip (Draft → Queued → Running → Finished/Error, timestamps on hover). Rendered by the + header; state derives from existing job status fields. + +### 3.2 Run monitor (F1, C2) — `@mat3ra/jove` + webapp + +- `ResultsTab` grows a monitor mode while the job is active: per-unit list with status, + duration, and progress; log tail; convergence chart streaming from the existing property + update channel (`onOutputUpdateRequest` / job properties refresh). On finish it settles into + today's results view. +- After a successful preflight submit (2.4), the designer navigates to the monitor instead of + staying on the editing view (C2). +- Data contract needs webapp work (log tail endpoint or polling adapter injected via + `setDependencies()`); standalone ships a simulated feed for the demo, mirroring the mockup. + +### 3.3 Unit inspector drawer (D3) and theme parity (D4) — `@mat3ra/workflow-designer` + +- Clicking a flowchart node opens a right-side drawer with that unit's important settings + (replacing the Overview / Important settings / Detailed view bounce). Keep the old sub-tabs + behind a prop until the webapp migrates. +- Flowchart pane colors move to CSS custom properties driven by the host theme so the dark + shell stops framing a white canvas. + +## Cross-cutting + +- **Injection seam:** every new data need (cluster metadata, quota, log tail, extra preflight + checks) enters through `setDependencies()` / `JobDesignerContext` with a package-native + fallback, exactly like `EntityHeaderComponent` and `FilesExplorerContainer` today. No Meteor + or Redux imports in package code (SOF-7991 direction). +- **Feature flag:** phase 2's layout ships behind a `useGuidedDesigner` flag (webapp-injected + boolean, default off) so webapp and standalone can flip independently; the legacy tabs path + remains until parity is verified, then is removed in a cleanup PR. +- **Naming:** per `AGENTS.md` — full descriptive names (`JobReadinessRail`, + `ComputeEstimatePanel`, `runPreflightChecks`), PascalCase components, no abbreviations. +- **Standalone demo as testbed:** every phase must be demonstrable in `npm run dev` with + standata content and stub metadata; the demo is the review surface for design sign-off. + +## Testing + +- Unit (node `--test`, `tests/`): `getJobReadiness`, `estimateComputeCost`, + `runPreflightChecks`, preset application, touched-state validation reducer. +- Component/e2e (Cypress, `tests/e2e`): create-job happy path through the rail; submit blocked + by a failing preflight then fixed; context-strip navigation; multi-material tray; monitor + simulation smoke test. +- Regression: webapp integration run (`web-app` Cypress UI suite) before flipping the flag, + since the webapp injects its own header/dialogs through the seam this plan touches. + +## Risks + +| Risk | Mitigation | +|------|------------| +| Cluster pricing/limits/queue data may not exist as a clean API | Estimate panel degrades: hide cost/ETA rows when metadata is absent; limits fall back to none | +| `ButtonMultiSelect` mount-snapshot bug class (stale closures in header buttons) | Read `this.state.entity` at click time (pattern already documented in `Job.jsx`); add a regression test | +| Rail layout breaks webapp deep links (`getRouteQueryTab`) | Keep tab ids stable; rail maps ids 1:1 to today's `TAB_NAVIGATION_CONFIG` | +| Cross-repo sequencing (ive / workflow-designer / cove / jove versions) | Land package changes behind additive props with safe defaults; bump versions in job-designer last | +| Dataset-driven jobs (`isUsingDataset`) diverge from the material path | Rail renders a Dataset step in place of Material; readiness selector covers both branches | + +## Open questions + +1. Where do pricing and quota live today — is there an existing accounting endpoint the webapp + can inject, or is this new backend work? (Blocks the cost half of 2.3; the limits half can + ship first.) +2. Should preflight warnings (e.g. convergence sanity) come from workflow model metadata or + stay host-injected only? Package-native heuristics risk false alarms. +3. Does the monitor (3.2) poll job properties or can the webapp provide a push channel? Polling + is acceptable for v1. +4. File SOF tickets per phase under the UX-574 umbrella — one per phase, or one per proposal + group? (Suggest one per phase.) From dedd8cb3fdced2e1ea8cd9a2225456bca5001d36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:52:42 +0000 Subject: [PATCH 03/33] docs: anchor guided-designer plan to SOF-8023 Renamed from UX-574 - SOF-8023 (Job Designer UX update: guided designer flow) is the implementation ticket for this plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ... => SOF-8023-Job-Designer-Guided-Designer-Plan.md} | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) rename plan/upcoming/{UX-574-Job-Designer-Guided-Designer-Plan.md => SOF-8023-Job-Designer-Guided-Designer-Plan.md} (97%) diff --git a/plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md similarity index 97% rename from plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md rename to plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md index 7fe35d8..29c604f 100644 --- a/plan/upcoming/UX-574-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md @@ -1,8 +1,7 @@ -# UX-574 — Job Designer: Guided Designer Implementation Plan +# SOF-8023 — Job Designer: Guided Designer Implementation Plan -**Ticket:** [UX-574](https://mat3ra.atlassian.net/browse/UX-574) (UI/UX Platform Review — August -2026) is the umbrella; each phase below should get its own SOF implementation ticket when picked -up (numbers to be filed). +**Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update: +guided designer flow. Per-phase implementation tickets can be filed under it when work starts. **Source proposals:** [`UIUX_IMPROVEMENTS.md`](../../UIUX_IMPROVEMENTS.md) (proposals A1–F2) and the interactive mockups in [`mockups/`](../../mockups/). **Branch of record for the brainstorm:** `claude/jd-ui-ux-improvements-99w9fg`. @@ -246,5 +245,5 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer stay host-injected only? Package-native heuristics risk false alarms. 3. Does the monitor (3.2) poll job properties or can the webapp provide a push channel? Polling is acceptable for v1. -4. File SOF tickets per phase under the UX-574 umbrella — one per phase, or one per proposal - group? (Suggest one per phase.) +4. File per-phase SOF tickets under SOF-8023 — one per phase, or one per proposal group? + (Suggest one per phase.) From 2d7be9f9604c10075238fdf44389493db7552399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 17:08:41 +0000 Subject: [PATCH 04/33] docs: elaborate SOF-8023 plan (cove design-language audit) + screenshots via LFS - Design-language section: cove theme/palette audit (skeletal paletteDark, status-color contrast defects, ButtonMultiSelect resync), new primitives table (StatusChip, SelectableCard, MetricTile, SegmentedMeter, ...), theme adoption + CSS custom-property bridge - Phase 1.5: design-language groundwork; phases now name the cove primitives - plan/context: current-state + mockup screenshots, tracked with Git LFS Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- .gitattributes | 1 + plan/context/SOF-8023-Screenshots.md | 24 +++ .../sof-8023-current-actions-dropdown.png | 3 + .../images/sof-8023-current-compute-tab.png | 3 + .../images/sof-8023-current-materials-tab.png | 3 + .../images/sof-8023-current-workflow-tab.png | 3 + .../sof-8023-mockup-01-guided-designer.png | 3 + .../sof-8023-mockup-02-compute-cost.png | 3 + .../sof-8023-mockup-03-preflight-submit.png | 3 + .../images/sof-8023-mockup-04-run-monitor.png | 3 + ...-8023-Job-Designer-Guided-Designer-Plan.md | 150 ++++++++++++++---- 11 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 .gitattributes create mode 100644 plan/context/SOF-8023-Screenshots.md create mode 100644 plan/context/images/sof-8023-current-actions-dropdown.png create mode 100644 plan/context/images/sof-8023-current-compute-tab.png create mode 100644 plan/context/images/sof-8023-current-materials-tab.png create mode 100644 plan/context/images/sof-8023-current-workflow-tab.png create mode 100644 plan/context/images/sof-8023-mockup-01-guided-designer.png create mode 100644 plan/context/images/sof-8023-mockup-02-compute-cost.png create mode 100644 plan/context/images/sof-8023-mockup-03-preflight-submit.png create mode 100644 plan/context/images/sof-8023-mockup-04-run-monitor.png diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/plan/context/SOF-8023-Screenshots.md b/plan/context/SOF-8023-Screenshots.md new file mode 100644 index 0000000..c3a2809 --- /dev/null +++ b/plan/context/SOF-8023-Screenshots.md @@ -0,0 +1,24 @@ +# SOF-8023 — Screenshots: current state and mockups + +Reference images for [`../upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md`](../upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md). +Current-state captures come from the standalone demo (`npm run dev`, standata content, +2026-08-15); mockup captures come from the interactive files in [`mockups/`](../../mockups/). +Stored via Git LFS (`*.png`, see `.gitattributes`). + +## Current state + +| | | +|---|---| +| ![Materials tab](images/sof-8023-current-materials-tab.png) | **Materials tab** — full-bleed 3D viewer, no metadata panel, no multi-material tray. | +| ![Workflow tab](images/sof-8023-current-workflow-tab.png) | **Workflow tab** — UUIDs and "idle" chips at design time, duplicated "Compute" sub-tab, light flowchart pane inside the dark shell. | +| ![Compute tab](images/sof-8023-current-compute-tab.png) | **Compute tab** — four required-field errors on first paint; bare selects; no cost, limits, or queue information. | +| ![Actions dropdown](images/sof-8023-current-actions-dropdown.png) | **"Select Job Actions" dropdown** — the entire creation path (and, in the webapp, Submit) hides here. | + +## Mockups + +| | | +|---|---| +| ![Guided designer](images/sof-8023-mockup-01-guided-designer.png) | **01 · Guided designer** — readiness rail, context strip, first-class Submit (proposals A1–A3, D2, E1–E2). | +| ![Compute and cost](images/sof-8023-mockup-02-compute-cost.png) | **02 · Compute & cost** — cluster cards, presets, live estimate with quota meter, progressive validation (B1–B4). | +| ![Preflight](images/sof-8023-mockup-03-preflight-submit.png) | **03 · Preflight & submit** — pass/warn/fail checks with deep-link fixes (C1–C2). | +| ![Run monitor](images/sof-8023-mockup-04-run-monitor.png) | **04 · Run monitor** — lifecycle timeline, per-unit progress, log tail, convergence chart (F1–F2). | diff --git a/plan/context/images/sof-8023-current-actions-dropdown.png b/plan/context/images/sof-8023-current-actions-dropdown.png new file mode 100644 index 0000000..7750634 --- /dev/null +++ b/plan/context/images/sof-8023-current-actions-dropdown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b703f1b8dfc03035b765f8420cfd01b0afe40493a3823a58eb3633dcb4cf51b6 +size 99033 diff --git a/plan/context/images/sof-8023-current-compute-tab.png b/plan/context/images/sof-8023-current-compute-tab.png new file mode 100644 index 0000000..52bfb3d --- /dev/null +++ b/plan/context/images/sof-8023-current-compute-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b64481cffa60cad79f962d17b131f3d10b7da9811710c3e1272d7f540eb4623f +size 93818 diff --git a/plan/context/images/sof-8023-current-materials-tab.png b/plan/context/images/sof-8023-current-materials-tab.png new file mode 100644 index 0000000..210110e --- /dev/null +++ b/plan/context/images/sof-8023-current-materials-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ef9cc6d58edf23bd8b3d7f4fed48c2aabef9ad811eb825467cbab44e2766ea5 +size 52180 diff --git a/plan/context/images/sof-8023-current-workflow-tab.png b/plan/context/images/sof-8023-current-workflow-tab.png new file mode 100644 index 0000000..f01e2fc --- /dev/null +++ b/plan/context/images/sof-8023-current-workflow-tab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e4dc7f4794b6abea4808deb0394065039bd282bc328a6b95b5debfd6619ebe2 +size 89488 diff --git a/plan/context/images/sof-8023-mockup-01-guided-designer.png b/plan/context/images/sof-8023-mockup-01-guided-designer.png new file mode 100644 index 0000000..c0f9b97 --- /dev/null +++ b/plan/context/images/sof-8023-mockup-01-guided-designer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9069f40f412e84365c9dc69ecee4ccadce8fb4f0e9ac48d166f17dd62dda7b7f +size 78219 diff --git a/plan/context/images/sof-8023-mockup-02-compute-cost.png b/plan/context/images/sof-8023-mockup-02-compute-cost.png new file mode 100644 index 0000000..d0907fc --- /dev/null +++ b/plan/context/images/sof-8023-mockup-02-compute-cost.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f37e44589fa58e8ca77965dc25c059c11b79fd3b12f4f36c4926312e0adb51c3 +size 84718 diff --git a/plan/context/images/sof-8023-mockup-03-preflight-submit.png b/plan/context/images/sof-8023-mockup-03-preflight-submit.png new file mode 100644 index 0000000..c87af1d --- /dev/null +++ b/plan/context/images/sof-8023-mockup-03-preflight-submit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ee71df85779904f5c9cf3742dafd5da9d96e2b69396c1a34e2fd26c7b3bd198 +size 60369 diff --git a/plan/context/images/sof-8023-mockup-04-run-monitor.png b/plan/context/images/sof-8023-mockup-04-run-monitor.png new file mode 100644 index 0000000..3c0ae32 --- /dev/null +++ b/plan/context/images/sof-8023-mockup-04-run-monitor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ddef300cd0b603fe09f1891a7119afdcbea892758ff5fe529da171a9015e2b0 +size 73552 diff --git a/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md index 29c604f..5193181 100644 --- a/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md @@ -35,12 +35,76 @@ The designer shell is `src/components/Job.jsx` (class component, mixins from `mi (`src/standalone/index.tsx`, `npm run dev`, port 3003) exercises the package-native fallbacks with standata workflows/materials. -Pain points, verified in the running standalone app (screenshots in the *Job Designer Next* -artifact): the creation path is hidden in the dropdown; numbered tabs carry no progress or -cross-tab context; the compute form shows four required-field errors on first paint and no cost -estimate; the workflow tab duplicates a "Compute" sub-tab, prints UUIDs and "idle" chips at -design time, and renders a light flowchart in the dark shell; there is no preflight at submit -and no live view after it. +Pain points, verified in the running standalone app (screenshots: [`plan/context/images/`](../context/images/) +and the *Job Designer Next* artifact): the creation path is hidden in the dropdown; numbered +tabs carry no progress or cross-tab context; the compute form shows four required-field errors +on first paint and no cost estimate; the workflow tab duplicates a "Compute" sub-tab, prints +UUIDs and "idle" chips at design time, and renders a light flowchart in the dark shell; there +is no preflight at submit and no live view after it. + +## Design language (cove): audit and required work + +The platform design language lives in `@mat3ra/cove` (`dist/theme`, `dist/mui`, +`dist/mui-composed`). Audit of the shipped package (2026.7.28-0): + +### What exists and is reusable + +- Theme pair `LightMaterialUITheme` / `DarkMaterialUITheme`, plus `commonSettings` (Roboto and + monospace font stacks, button size scale, breakpoints). The default export is still + `oldLightMaterialUITheme`, which is what the webapp consumes today. +- Palette slots beyond stock MUI: `unitTypes` (execution / condition / assignment / assertion), + `border`, `icon` — already used by workflow surfaces. +- Primitives the guided designer can reuse directly: `TabsMenu`, `StyledStepper` (linear, + label-only), `LinearProgress`, dialogs, `Dropdown` / `NestedDropdown`, `RadioGroup`, + `IconByName` (~370-entry `entities.* / actions.* / shapes.*` map), `ResizableDrawer`, + `AlertProvider`, and the composed `EntityHeader` / `EntityName` / loading components. + +### Defects to fix first (cove PRs; prerequisites for the phases below) + +- **`paletteDark` is skeletal**: only `primary`, `secondary`, and the four status colors — no + `background`, `text`, `action`, `border`, `icon`, or `unitTypes` slots. Anything reading + `theme.palette.border.main` or `unitTypes.*` in dark mode gets `undefined`. This is the root + of the light-flowchart-in-dark-shell problem (D4) and the reason the standalone demo rolls + its own `createTheme` (`src/standalone/index.tsx` `demoTheme`). +- **Status colors are not accessible as shipped**: `success.main` `#72E128` (neon green) + declares `contrastText #FFFFFF` (~1.6:1 contrast); `error.contrastText` is + `rgba(0, 0, 0, 0.23)` — 23%-alpha black on red. Chips or badges built on these are + unreadable. Re-derive the four statuses with passing contrast and add "soft" surface variants + (tinted background + strong foreground) for chips, badges, and flag rows — the pattern every + mockup uses. +- **No job-status semantic mapping**: draft / queued / running / finished / error / terminated + → color + icon currently lives ad hoc in consumers (`iconCls: text-${job.statusCls}`). + Define the mapping once in cove so the header chip, readiness rail, unit pills, and monitor + agree. +- **`ButtonMultiSelect` snapshots `buttonConfigs` on mount** and never resyncs (workaround + documented at length in `Job.jsx#getSaveBtnProps`). Fix the resync in cove, then delete the + workaround. + +### New primitives needed (cove additions; each small and generic) + +| Primitive | Used by | Phase | +|-----------|---------|-------| +| `StatusChip` — icon + label pill in status-soft colors | lifecycle header, rail, unit list, queue badges | 1, 3 | +| `CopyId` — truncated id behind a copy icon + tooltip | D2 | 1 | +| `SelectableCard` — radio-behavior card | cluster picker (B1), presets (B3) | 2 | +| `MetricTile` — label + value + unit, tabular numerals | compute summary, estimate panel, results strip | 2, 3 | +| `SegmentedMeter` — used + this-job + remaining | budget/quota display (B2) | 2 | +| `NumericStepperInput` — − value +, min/max, unit suffix | nodes / cores / walltime (B1) | 2 | +| `LifecycleTimeline` — phases with timestamps and states | F2, monitor header | 3 | +| `LogViewer` — monospace, tail-follow, filterable | monitor (F1) | 3 | + +### Theme adoption and token bridge + +- Promote the current theme pair to the default (deprecate `oldLightMaterialUITheme` behind an + explicit import), complete `paletteDark`, and switch the standalone demo to + `DarkMaterialUITheme`, deleting its private `demoTheme` — the demo becomes the dogfood + surface for the dark palette. +- Export the palette as CSS custom properties from cove's `ThemeProvider` (e.g. + `--m3-surface`, `--m3-border`, `--m3-status-running`) so non-MUI surfaces — the flowchart + canvas and unit nodes — consume the same tokens (D4) without importing MUI at render time. +- Typography for data: estimate, logs, ids, and durations use `commonSettings.fonts.monospace` + and `font-variant-numeric: tabular-nums`; codify this inside the new primitives instead of + per-app CSS. ## Goals @@ -56,7 +120,9 @@ and no live view after it. - No redesign of the workflow editing experience itself (unit graph editing, subworkflow composition) — that is UX-500 territory. - No changes to job submission backend contracts; preflight consumes existing data. -- No visual rebrand: reuse the platform theme; mockups' dark palette is illustrative only. +- No visual rebrand: the work completes and repairs the existing cove theme system (see the + design-language section) rather than introducing a new one; the mockups' exact palette is + illustrative. ## Phase 1 — De-noise (proposals B4, D1, D2, A3) @@ -98,6 +164,22 @@ revertable. - Acceptance: a draft job with material+workflow+compute set can be submitted in one click from any tab; the disabled button explains what is missing; webapp header parity is preserved. +### 1.5 Design-language groundwork (cove) + +- Fix the status palette (contrast + soft variants) and ship `StatusChip` and `CopyId`; + complete the missing `paletteDark` slots (`background`, `text`, `action`, `border`, `icon`, + `unitTypes`). +- Export the job-status semantic mapping (draft / queued / running / finished / error / + terminated → color + icon) from cove; items 1.3 and 1.4 consume it instead of + `text-${job.statusCls}` classes. +- Fix `ButtonMultiSelect` config resync; delete the click-time-read workaround note in + `Job.jsx` once the fixed version is consumed. +- Switch the standalone demo to cove's `DarkMaterialUITheme` (delete `demoTheme` in + `src/standalone/index.tsx`) so the completed dark palette is exercised continuously. +- Acceptance: chips and header render legibly in both themes (visual check in the cove + gallery, cove#92); the demo runs without a private theme; no consumer reads an undefined + dark-palette slot. + ## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, @@ -118,8 +200,10 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, so webapp and standalone agree on the sequence. - The three "Select …" dialogs stay as they are, but open from "Change" affordances on their steps. -- Layout: rail left (fixed ~260 px, collapses to horizontal stepper under 760 px), content - right. Keep the DOM of tab panels unchanged where possible so Cypress selectors survive. +- Layout: rail left (fixed ~260 px, collapses to a horizontal stepper under 760 px — the + compact variant can extend cove's existing `StyledStepper`), content right. Step state + renders with cove `StatusChip` colors from the job-status mapping (1.5). Keep the DOM of tab + panels unchanged where possible so Cypress selectors survive. - Acceptance: a new user can create and submit a job without opening any dropdown; deep links via `getRouteQueryTab` still land on the right step. @@ -133,13 +217,15 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, ### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` -- Cluster picker becomes selectable cards (name, hardware summary, per-core-hour price, queue - wait badge) fed by the existing `clusters` prop; job-designer passes an optional +- Cluster picker becomes selectable cards — cove `SelectableCard` with a `StatusChip` queue + badge — fed by the existing `clusters` prop; job-designer passes an optional `clusterMetadata` enrichment (pricing, limits, queue wait) injected by the webapp through - `setDependencies()` — standalone falls back to static demo data. -- New `ComputeEstimatePanel`: core-hours = nodes × cores × walltime, price, queue ETA, monthly - quota meter. Pure function `estimateComputeCost(computeConfiguration, clusterMetadata)` - lives beside the panel and is unit-tested. + `setDependencies()` — standalone falls back to static demo data. Nodes / cores / walltime + become cove `NumericStepperInput`s with min/max from `clusterMetadata`. +- New `ComputeEstimatePanel` built from cove `MetricTile`s and a `SegmentedMeter` for quota: + core-hours = nodes × cores × walltime, price, queue ETA, monthly quota. Pure function + `estimateComputeCost(computeConfiguration, clusterMetadata)` lives beside the panel and is + unit-tested. - Presets row (Debug / Standard / Production / same-as-last-job) writes through the normal `onUpdate(compute)` path so undo/save semantics are untouched. - Validation limits (max nodes, cores per node, queue walltime caps) come from @@ -179,16 +265,18 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer ### 3.1 Lifecycle header (F2) — `@mat3ra/cove` + job-designer -- Replace the status-colored icon (`iconCls: text-${job.statusCls}`) with a lifecycle timeline - chip (Draft → Queued → Running → Finished/Error, timestamps on hover). Rendered by the - header; state derives from existing job status fields. +- Replace the status-colored icon (`iconCls: text-${job.statusCls}`) with cove's + `LifecycleTimeline` (Draft → Queued → Running → Finished/Error, timestamps on hover), + colored by the job-status mapping from 1.5. Rendered by the header; state derives from + existing job status fields. ### 3.2 Run monitor (F1, C2) — `@mat3ra/jove` + webapp -- `ResultsTab` grows a monitor mode while the job is active: per-unit list with status, - duration, and progress; log tail; convergence chart streaming from the existing property - update channel (`onOutputUpdateRequest` / job properties refresh). On finish it settles into - today's results view. +- `ResultsTab` grows a monitor mode while the job is active: per-unit list with `StatusChip` + states, durations, and progress; a cove `LogViewer` tail; convergence chart streaming from + the existing property update channel (`onOutputUpdateRequest` / job properties refresh), + drawn with cove tokens (single series, recessive grid, emphasized endpoint). On finish it + settles into today's results view with a `MetricTile` summary strip. - After a successful preflight submit (2.4), the designer navigates to the monitor instead of staying on the editing view (C2). - Data contract needs webapp work (log tail endpoint or polling adapter injected via @@ -196,11 +284,13 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer ### 3.3 Unit inspector drawer (D3) and theme parity (D4) — `@mat3ra/workflow-designer` -- Clicking a flowchart node opens a right-side drawer with that unit's important settings - (replacing the Overview / Important settings / Detailed view bounce). Keep the old sub-tabs - behind a prop until the webapp migrates. -- Flowchart pane colors move to CSS custom properties driven by the host theme so the dark - shell stops framing a white canvas. +- Clicking a flowchart node opens a right-side drawer — built on cove's existing + `ResizableDrawer` — with that unit's important settings (replacing the Overview / Important + settings / Detailed view bounce). Keep the old sub-tabs behind a prop until the webapp + migrates. +- Flowchart pane colors move to the CSS custom properties exported by cove's `ThemeProvider` + (design-language section) so the dark shell stops framing a white canvas; `unitTypes.*` + colors come from the completed dark palette. ## Cross-cutting @@ -211,6 +301,10 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer - **Feature flag:** phase 2's layout ships behind a `useGuidedDesigner` flag (webapp-injected boolean, default off) so webapp and standalone can flip independently; the legacy tabs path remains until parity is verified, then is removed in a cleanup PR. +- **Design language:** all new UI is composed from the cove primitives listed in the + design-language section — no app-local colors or one-off chips; job statuses always come + from the cove job-status mapping. New primitives land in cove with gallery entries + (cove#92 pattern) before consumers use them. - **Naming:** per `AGENTS.md` — full descriptive names (`JobReadinessRail`, `ComputeEstimatePanel`, `runPreflightChecks`), PascalCase components, no abbreviations. - **Standalone demo as testbed:** every phase must be demonstrable in `npm run dev` with @@ -235,6 +329,8 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer | Rail layout breaks webapp deep links (`getRouteQueryTab`) | Keep tab ids stable; rail maps ids 1:1 to today's `TAB_NAVIGATION_CONFIG` | | Cross-repo sequencing (ive / workflow-designer / cove / jove versions) | Land package changes behind additive props with safe defaults; bump versions in job-designer last | | Dataset-driven jobs (`isUsingDataset`) diverge from the material path | Rail renders a Dataset step in place of Material; readiness selector covers both branches | +| Fixing the status palette recolors screens already shipped on the old values | Land palette fixes as a dedicated cove PR with before/after gallery screenshots and design review; consumers pick the bump explicitly | +| Completing `paletteDark` changes dark-mode rendering of existing cove consumers | Additive slots only (no changes to existing light values); verify against the cove gallery and the standalone demo before releasing | ## Open questions From 790c7d27739e83a29f5c43e10b2b4b80acedd09e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 17:31:24 +0000 Subject: [PATCH 05/33] docs: date-based plan filenames; tracker ticket moves into document text Repos are public but the tracker is private - file names now carry a YYYY-MM-DD date, the Jira key stays inside the document. Naming convention updated in AGENTS.md section 6 and plan/README.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- AGENTS.md | 6 +++-- plan/README.md | 7 ++++-- .../2026-08-16-Job-Designer-Screenshots.md | 25 +++++++++++++++++++ plan/context/SOF-8023-Screenshots.md | 24 ------------------ ...> 2026-08-16-current-actions-dropdown.png} | 0 ...png => 2026-08-16-current-compute-tab.png} | 0 ...g => 2026-08-16-current-materials-tab.png} | 0 ...ng => 2026-08-16-current-workflow-tab.png} | 0 ... 2026-08-16-mockup-01-guided-designer.png} | 0 ... => 2026-08-16-mockup-02-compute-cost.png} | 0 ...2026-08-16-mockup-03-preflight-submit.png} | 0 ...g => 2026-08-16-mockup-04-run-monitor.png} | 0 ...8-16-Job-Designer-Guided-Designer-Plan.md} | 0 13 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 plan/context/2026-08-16-Job-Designer-Screenshots.md delete mode 100644 plan/context/SOF-8023-Screenshots.md rename plan/context/images/{sof-8023-current-actions-dropdown.png => 2026-08-16-current-actions-dropdown.png} (100%) rename plan/context/images/{sof-8023-current-compute-tab.png => 2026-08-16-current-compute-tab.png} (100%) rename plan/context/images/{sof-8023-current-materials-tab.png => 2026-08-16-current-materials-tab.png} (100%) rename plan/context/images/{sof-8023-current-workflow-tab.png => 2026-08-16-current-workflow-tab.png} (100%) rename plan/context/images/{sof-8023-mockup-01-guided-designer.png => 2026-08-16-mockup-01-guided-designer.png} (100%) rename plan/context/images/{sof-8023-mockup-02-compute-cost.png => 2026-08-16-mockup-02-compute-cost.png} (100%) rename plan/context/images/{sof-8023-mockup-03-preflight-submit.png => 2026-08-16-mockup-03-preflight-submit.png} (100%) rename plan/context/images/{sof-8023-mockup-04-run-monitor.png => 2026-08-16-mockup-04-run-monitor.png} (100%) rename plan/upcoming/{SOF-8023-Job-Designer-Guided-Designer-Plan.md => 2026-08-16-Job-Designer-Guided-Designer-Plan.md} (100%) diff --git a/AGENTS.md b/AGENTS.md index 72e3ab1..f1e3054 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,6 +227,8 @@ it is part of doing the work — not bookkeeping to be done later: Never edit a document in `implemented/` to match the code — rewriting history loses the reason a decision was made, which is the only thing the document is still good for; correct it with a -`## Status` note instead. Name documents `-.md`, e.g. -`SOF-8010-Containerized-Venv-Plan.md`. The canonical `plan/README.md` to copy when introducing +`## Status` note instead. Name documents `-.md`, e.g. +`2026-08-16-Containerized-Venv-Plan.md` — dated, with the tracker ticket referenced inside the +document text (a `**Ticket:**` line at the top), not in the file name: repositories are public +while the tracker is private. The canonical `plan/README.md` to copy when introducing the folder to a repository lives in `mat3ra/agents` under `templates/plan/README.md`. diff --git a/plan/README.md b/plan/README.md index 2769b00..1edf59f 100644 --- a/plan/README.md +++ b/plan/README.md @@ -38,5 +38,8 @@ say so in the commit message. ## Naming -`-.md`, e.g. `SOF-8010-Containerized-Venv-Plan.md`. One ticket can have -several documents; keep them in the same folder only while they share a status. +`-.md`, e.g. `2026-08-16-Containerized-Venv-Plan.md` — dated by when +the document was started. The tracker ticket goes inside the document (a `**Ticket:**` line at +the top), not in the file name: these repositories are public, while ticket keys point at a +private tracker that readers outside the organization cannot open. One ticket can have several +documents; keep them in the same folder only while they share a status. diff --git a/plan/context/2026-08-16-Job-Designer-Screenshots.md b/plan/context/2026-08-16-Job-Designer-Screenshots.md new file mode 100644 index 0000000..225d10d --- /dev/null +++ b/plan/context/2026-08-16-Job-Designer-Screenshots.md @@ -0,0 +1,25 @@ +# Job Designer screenshots: current state and mockups + +**Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +Reference images for [`../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md`](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md). +Current-state captures come from the standalone demo (`npm run dev`, standata content, +2026-08-15); mockup captures come from the interactive files in [`mockups/`](../../mockups/). +Stored via Git LFS (`*.png`, see `.gitattributes`). + +## Current state + +| | | +|---|---| +| ![Materials tab](images/2026-08-16-current-materials-tab.png) | **Materials tab** — full-bleed 3D viewer, no metadata panel, no multi-material tray. | +| ![Workflow tab](images/2026-08-16-current-workflow-tab.png) | **Workflow tab** — UUIDs and "idle" chips at design time, duplicated "Compute" sub-tab, light flowchart pane inside the dark shell. | +| ![Compute tab](images/2026-08-16-current-compute-tab.png) | **Compute tab** — four required-field errors on first paint; bare selects; no cost, limits, or queue information. | +| ![Actions dropdown](images/2026-08-16-current-actions-dropdown.png) | **"Select Job Actions" dropdown** — the entire creation path (and, in the webapp, Submit) hides here. | + +## Mockups + +| | | +|---|---| +| ![Guided designer](images/2026-08-16-mockup-01-guided-designer.png) | **01 · Guided designer** — readiness rail, context strip, first-class Submit (proposals A1–A3, D2, E1–E2). | +| ![Compute and cost](images/2026-08-16-mockup-02-compute-cost.png) | **02 · Compute & cost** — cluster cards, presets, live estimate with quota meter, progressive validation (B1–B4). | +| ![Preflight](images/2026-08-16-mockup-03-preflight-submit.png) | **03 · Preflight & submit** — pass/warn/fail checks with deep-link fixes (C1–C2). | +| ![Run monitor](images/2026-08-16-mockup-04-run-monitor.png) | **04 · Run monitor** — lifecycle timeline, per-unit progress, log tail, convergence chart (F1–F2). | diff --git a/plan/context/SOF-8023-Screenshots.md b/plan/context/SOF-8023-Screenshots.md deleted file mode 100644 index c3a2809..0000000 --- a/plan/context/SOF-8023-Screenshots.md +++ /dev/null @@ -1,24 +0,0 @@ -# SOF-8023 — Screenshots: current state and mockups - -Reference images for [`../upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md`](../upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md). -Current-state captures come from the standalone demo (`npm run dev`, standata content, -2026-08-15); mockup captures come from the interactive files in [`mockups/`](../../mockups/). -Stored via Git LFS (`*.png`, see `.gitattributes`). - -## Current state - -| | | -|---|---| -| ![Materials tab](images/sof-8023-current-materials-tab.png) | **Materials tab** — full-bleed 3D viewer, no metadata panel, no multi-material tray. | -| ![Workflow tab](images/sof-8023-current-workflow-tab.png) | **Workflow tab** — UUIDs and "idle" chips at design time, duplicated "Compute" sub-tab, light flowchart pane inside the dark shell. | -| ![Compute tab](images/sof-8023-current-compute-tab.png) | **Compute tab** — four required-field errors on first paint; bare selects; no cost, limits, or queue information. | -| ![Actions dropdown](images/sof-8023-current-actions-dropdown.png) | **"Select Job Actions" dropdown** — the entire creation path (and, in the webapp, Submit) hides here. | - -## Mockups - -| | | -|---|---| -| ![Guided designer](images/sof-8023-mockup-01-guided-designer.png) | **01 · Guided designer** — readiness rail, context strip, first-class Submit (proposals A1–A3, D2, E1–E2). | -| ![Compute and cost](images/sof-8023-mockup-02-compute-cost.png) | **02 · Compute & cost** — cluster cards, presets, live estimate with quota meter, progressive validation (B1–B4). | -| ![Preflight](images/sof-8023-mockup-03-preflight-submit.png) | **03 · Preflight & submit** — pass/warn/fail checks with deep-link fixes (C1–C2). | -| ![Run monitor](images/sof-8023-mockup-04-run-monitor.png) | **04 · Run monitor** — lifecycle timeline, per-unit progress, log tail, convergence chart (F1–F2). | diff --git a/plan/context/images/sof-8023-current-actions-dropdown.png b/plan/context/images/2026-08-16-current-actions-dropdown.png similarity index 100% rename from plan/context/images/sof-8023-current-actions-dropdown.png rename to plan/context/images/2026-08-16-current-actions-dropdown.png diff --git a/plan/context/images/sof-8023-current-compute-tab.png b/plan/context/images/2026-08-16-current-compute-tab.png similarity index 100% rename from plan/context/images/sof-8023-current-compute-tab.png rename to plan/context/images/2026-08-16-current-compute-tab.png diff --git a/plan/context/images/sof-8023-current-materials-tab.png b/plan/context/images/2026-08-16-current-materials-tab.png similarity index 100% rename from plan/context/images/sof-8023-current-materials-tab.png rename to plan/context/images/2026-08-16-current-materials-tab.png diff --git a/plan/context/images/sof-8023-current-workflow-tab.png b/plan/context/images/2026-08-16-current-workflow-tab.png similarity index 100% rename from plan/context/images/sof-8023-current-workflow-tab.png rename to plan/context/images/2026-08-16-current-workflow-tab.png diff --git a/plan/context/images/sof-8023-mockup-01-guided-designer.png b/plan/context/images/2026-08-16-mockup-01-guided-designer.png similarity index 100% rename from plan/context/images/sof-8023-mockup-01-guided-designer.png rename to plan/context/images/2026-08-16-mockup-01-guided-designer.png diff --git a/plan/context/images/sof-8023-mockup-02-compute-cost.png b/plan/context/images/2026-08-16-mockup-02-compute-cost.png similarity index 100% rename from plan/context/images/sof-8023-mockup-02-compute-cost.png rename to plan/context/images/2026-08-16-mockup-02-compute-cost.png diff --git a/plan/context/images/sof-8023-mockup-03-preflight-submit.png b/plan/context/images/2026-08-16-mockup-03-preflight-submit.png similarity index 100% rename from plan/context/images/sof-8023-mockup-03-preflight-submit.png rename to plan/context/images/2026-08-16-mockup-03-preflight-submit.png diff --git a/plan/context/images/sof-8023-mockup-04-run-monitor.png b/plan/context/images/2026-08-16-mockup-04-run-monitor.png similarity index 100% rename from plan/context/images/sof-8023-mockup-04-run-monitor.png rename to plan/context/images/2026-08-16-mockup-04-run-monitor.png diff --git a/plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md similarity index 100% rename from plan/upcoming/SOF-8023-Job-Designer-Guided-Designer-Plan.md rename to plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md From 797a6db9f8f29e1e732e5bd44a741e75e9d9f8b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 17:48:33 +0000 Subject: [PATCH 06/33] docs: plan review pass - lifecycle coverage, sequencing, metrics, sizes Self-review additions: parent-job placement in the context strip (SOF-7962 flow), rail behavior across the full lifecycle and read-only jobs, visible error state (1.6), save-state honesty (2.6, UX-498), advanced compute options preserved, performance guardrail around persistJob/renderGeneration, accessibility and localization notes, release sequencing order, success metrics with baseline, S/M/L sizes per item, two new open questions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...08-16-Job-Designer-Guided-Designer-Plan.md | 116 ++++++++++++++++-- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index 5193181..f52db8f 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -136,6 +136,7 @@ revertable. `validate()` ref or a `showAllErrors` prop). - Acceptance: opening the Compute tab of a fresh job shows zero red errors; leaving a required field empty after focusing it shows exactly one; `showAllErrors` restores today's behavior. + Size: S–M. ### 1.2 Remove the duplicated Compute sub-tab (D1) — `@mat3ra/workflow-designer` @@ -143,6 +144,7 @@ revertable. Compute) drops the Compute entry when the host renders its own compute surface. Add a `hideComputeSubTab` (default false for backward compatibility) prop; job-designer passes true. - Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. + Size: S. ### 1.3 Humane metadata (D2) — `@mat3ra/workflow-designer` + job-designer @@ -151,6 +153,7 @@ revertable. gates them — job-designer passes the flag; the workflow pane already receives `adjustable={job.isInInitialStatus}` so thread one more boolean, e.g. `showUnitStatus`). - Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. + Size: M. ### 1.4 First-class Submit in the header (A3) — job-designer + `@mat3ra/cove` @@ -161,8 +164,11 @@ revertable. import-style power actions. - Mind the known cove `ButtonMultiSelect` mount-snapshot behavior (see the long note in `Job.jsx#getSaveBtnProps`): read state at click time, never capture the entity at render. +- Terminate is destructive: it gets a confirm step (job name + elapsed time in the dialog), + unlike today's straight dropdown action. - Acceptance: a draft job with material+workflow+compute set can be submitted in one click from - any tab; the disabled button explains what is missing; webapp header parity is preserved. + any tab; the disabled button explains what is missing; Terminate asks before killing a run; + webapp header parity is preserved. Size: M. ### 1.5 Design-language groundwork (cove) @@ -178,7 +184,16 @@ revertable. `src/standalone/index.tsx`) so the completed dark palette is exercised continuously. - Acceptance: chips and header render legibly in both themes (visual check in the cove gallery, cove#92); the demo runs without a private theme; no consumer reads an undefined - dark-palette slot. + dark-palette slot. Size: M–L. + +### 1.6 Visible error state (new; not in the original proposals) + +- `Job.jsx` wraps the designer in `ErrorBoundary` with `fallback={
}` — a render crash + currently produces a silently blank page. Replace with a visible error card (what failed, a + reload affordance, and a copyable error digest); the webapp can inject its reporting hook + through the seam. +- Acceptance: a thrown render error shows the error card in both webapp and standalone; the + fallback never renders an empty page. Size: S. ## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) @@ -195,7 +210,14 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, - Step state derives from a new pure selector module (proposed `src/jobReadiness.ts`): `getJobReadiness(job, materials) → { steps: [{ id, state, summary }], isSubmittable, blockingReasons }`. Unit-test this module heavily; it also drives the Submit button and - preflight. + preflight. It must cover all creation shapes: material jobs, dataset jobs + (`workflow.isUsingDataset`), multi-material sets (`materialsSet` / `isMultiMaterial`), and + parent-derived jobs (parent supplies the material — the Material step reads "from parent + job", not "missing"). +- **The rail spans the whole lifecycle, not just creation.** After submission the creation + steps collapse into read-only summaries and Monitor (later Results, Files) become the active + steps — this replaces today's `defaultTab` status-jumping logic. With `editable={false}` + (shared/public jobs) the rail renders view-only: no "Change" affordances, no Submit. - `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) grows optional per-tab step metadata (order, label) so webapp and standalone agree on the sequence. - The three "Select …" dialogs stay as they are, but open from "Change" affordances on their @@ -205,7 +227,9 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, renders with cove `StatusChip` colors from the job-status mapping (1.5). Keep the DOM of tab panels unchanged where possible so Cypress selectors survive. - Acceptance: a new user can create and submit a job without opening any dropdown; deep links - via `getRouteQueryTab` still land on the right step. + via `getRouteQueryTab` still land on the right step; a finished job opens on Monitor/Results + with creation steps summarized; the rail is keyboard-navigable (arrow keys between steps, + visible focus, `aria-current` on the active step). Size: L. ### 2.2 Context strip (A2) — job-designer @@ -213,7 +237,12 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, (name · subworkflow/unit counts), compute (cluster · nodes×cores · walltime), estimate (core-hours ≈ cost). Chips navigate to their step; incomplete chips render in the attention style. Data comes from the same `getJobReadiness` selector plus the estimate helper (2.3). -- Acceptance: on every step, the other selections stay visible; clicking a chip switches step. +- The parent job moves here too: today it renders as a dismissable `Alert` above the tabs + (`Job.jsx#renderParentJob`); it becomes a context chip (parent name · project) with the + remove affordance in its popover — same `setParent` / `unsetParent` model calls (the flow + fixed in SOF-7962). +- Acceptance: on every step, the other selections stay visible; clicking a chip switches step; + a parent-derived job shows the parent chip and no orphaned Alert. Size: S–M. ### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` @@ -230,9 +259,12 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, `onUpdate(compute)` path so undo/save semantics are untouched. - Validation limits (max nodes, cores per node, queue walltime caps) come from `clusterMetadata`; violations render inline (not red-on-first-paint — 1.1's touched logic). +- The advanced-options section (`showAdvancedComputeOptions`, gated today by the applications' + `hasAdvancedComputeOptions` in `Job.jsx`) is preserved as a collapsed "Advanced" group below + the cards — redesign must not drop the espresso-class options. - Acceptance: changing any field updates the estimate synchronously; exceeding a limit flags - the field and the estimate panel; presets fill the form in one click; webapp data path and - standalone fallback both render. + the field and the estimate panel; presets fill the form in one click; advanced options remain + reachable; webapp data path and standalone fallback both render. Size: L. ### 2.4 Preflight at submit (C1) — job-designer (+ webapp data) @@ -246,6 +278,7 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, the webapp can add checks (e.g. balance) via `setDependencies()`. - Acceptance: submitting an incomplete job is impossible through the UI; every fail row's action lands on the field that fixes it; checks are unit-tested including the warn/ack flow. + Size: M–L. ### 2.5 Materials tray and metadata (E1, E2) — job-designer (+ viewer package) @@ -257,7 +290,20 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, - Metadata side panel (formula, lattice, space group, atom count, source id) rendered from the `Material` model next to the injected viewer; the viewer component API is unchanged. - Acceptance: adding a second material updates the tray, the batch copy, and the context strip; - removing the active material selects a sane neighbor. + removing the active material selects a sane neighbor. Size: M. + +### 2.6 Save-state honesty (new; relates to UX-498) + +- The header states the truth about persistence: "Saved" only after the entity actually + persisted through `onSave` / the `shouldPersistJobOnUpdate` pipeline, "Unsaved changes" + otherwise, and a leave-guard (browser `beforeunload` + router guard injected via the seam) + when a dirty draft is about to be abandoned. The mockups' "All changes saved" copy is the + target state; showing it without it being true would be worse than today. +- Explicit non-goal here: no new autosave backend — this item only surfaces existing state + honestly. If product wants real autosave (UX-498 direction), that is a separate ticket. +- Acceptance: editing any field flips the indicator to dirty; Save flips it back; closing the + tab with a dirty draft warns; the indicator never claims "Saved" while in-memory state + differs from the persisted entity. Size: S–M. ## Phase 3 — The living job (proposals F1, F2, C2, D3, D4) @@ -268,7 +314,7 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer - Replace the status-colored icon (`iconCls: text-${job.statusCls}`) with cove's `LifecycleTimeline` (Draft → Queued → Running → Finished/Error, timestamps on hover), colored by the job-status mapping from 1.5. Rendered by the header; state derives from - existing job status fields. + existing job status fields. Size: S–M. ### 3.2 Run monitor (F1, C2) — `@mat3ra/jove` + webapp @@ -281,6 +327,7 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer staying on the editing view (C2). - Data contract needs webapp work (log tail endpoint or polling adapter injected via `setDependencies()`); standalone ships a simulated feed for the demo, mirroring the mockup. + Size: L–XL (the largest single item; the webapp data adapter is half of it). ### 3.3 Unit inspector drawer (D3) and theme parity (D4) — `@mat3ra/workflow-designer` @@ -291,6 +338,7 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer - Flowchart pane colors move to the CSS custom properties exported by cove's `ThemeProvider` (design-language section) so the dark shell stops framing a white canvas; `unitTypes.*` colors come from the completed dark palette. +- Size: M–L. ## Cross-cutting @@ -309,6 +357,51 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer `ComputeEstimatePanel`, `runPreflightChecks`), PascalCase components, no abbreviations. - **Standalone demo as testbed:** every phase must be demonstrable in `npm run dev` with standata content and stub metadata; the demo is the review surface for design sign-off. +- **Performance guardrail:** `Job.jsx` has delicate update machinery — `shouldComponentUpdate` + mixins, `renderGeneration`, and `persistJob()` runs `job.render()` (template rendering) on + workflow-tab entry. Readiness and estimate recomputation must be memoized pure derivation + that never calls `persistJob` or bumps `renderGeneration`; a compute keystroke must not + trigger a workflow re-render. Add a regression test that counts `job.render()` calls during + compute edits. +- **Accessibility:** rail and dialogs are keyboard-operable (focus trap in `PreflightDialog`, + `aria-current` on the active step, visible focus states); status is never conveyed by color + alone (icons + labels on every `StatusChip`); the palette work in 1.5 fixes the contrast + side. +- **Localization:** the webapp localizes via TAPi18n (stubbed as `createMessageTextTAPi18n` in + `Job.jsx`); new user-facing strings (readiness summaries, preflight messages, save-state + copy) go through an injectable message resolver on the seam with English fallbacks — no + hard-coded strings scattered through components. +- **Shared with the Materials Designer effort:** the cove primitives and palette repairs in + 1.5 serve the parallel materials-designer UX update too — keep primitive APIs generic (no + job-specific props) and land cove work first so both designers consume the same release. + +### Release sequencing + +Additive props with safe defaults everywhere, so no lockstep release is required. Order: + +1. `cove` (1.5, palette + primitives) — everything else consumes it. +2. `ive` (1.1, 2.3), `workflow-designer` (1.2, 1.3, 3.3), `jove` (3.2) — in parallel, each + behind default-off props. +3. `jode` (2.1 step metadata). +4. `job-designer` — version bumps + the shell work (1.4, 1.6, 2.1, 2.2, 2.4, 2.5, 2.6), + verified in the standalone demo. +5. `web-app` — pin bumps, seam wiring (`registerDependencies`), flag flip after the Cypress + suite is green. + +## Success metrics + +Instrument before flipping the `useGuidedDesigner` flag so there is a baseline. Events go +through an analytics hook injected via the seam (no-op in standalone): + +- **Time to first submit** for a new job (open designer → successful submit) — the headline + number the redesign should move. +- **Preflight outcomes**: fail/warn rates per check, and how often a deep-link fix is used — + high fail rates identify which step's affordances still fail users. +- **Abandonment**: drafts opened vs. submitted, and the step where users leave. +- **Terminate-after-submit within N minutes** — a proxy for "submitted with wrong settings", + which the estimate + preflight should reduce. +- **Monitor engagement** (phase 3): share of running jobs whose owners watch the monitor vs. + navigate away. ## Testing @@ -343,3 +436,8 @@ Mockup: `04-run-monitor.html`. Mostly lands in dependency packages; job-designer is acceptable for v1. 4. File per-phase SOF tickets under SOF-8023 — one per phase, or one per proposal group? (Suggest one per phase.) +5. Which analytics channel should the success-metrics events use — the webapp's existing + telemetry, or is this the moment to add a product-analytics hook to the seam? (Gates the + baseline capture, not the UI work.) +6. Localization: is English-only acceptable for the new strings at launch (matching the + packages' current state), with the message-resolver seam making them translatable later? From 98bf90deaeaf08bbd5ad350eb94bfc99254b9bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:30:32 +0000 Subject: [PATCH 07/33] feat: first-class Submit, visible error state, single Compute tab Submit and Terminate were items in the 'Select Job Actions' dropdown, shown only when job.id && job.isInInitialStatus. Hidden is the worst state for a control: the reader cannot act on it and is not told why. - Submit is a header button, always visible while the job is a draft. When it is disabled it names what is missing ('Select a material (+2 more)'), from a pure getSubmitBlockers() the readiness rail and submit preflight will reuse in phase 2. - Terminate replaces it while the job runs, and now asks before killing the run instead of acting on one unconfirmed click. - ErrorBoundary fallback was
, so a render error produced a silently blank page. It is now a card naming what happened, with a reload and a copyable digest. - Pass hideComputeSubTab to the workflow pane so the screen offers one Compute tab, not one per subworkflow. Note the shouldComponentUpdate addition: this component's mixins only consider the job entity, so without it the terminate confirmation could never render. SOF-8023 phases 1.4, 1.6 and the consumer half of 1.2. Plan updated with a correction: the UUIDs and status chips of 1.3 render in @mat3ra/wove, not workflow-designer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...08-16-Job-Designer-Guided-Designer-Plan.md | 20 ++- src/components/Job.jsx | 167 ++++++++++++++++-- src/components/WorkflowTab.tsx | 3 + src/jobSubmission.ts | 84 +++++++++ tests/jobSubmission.tests.ts | 95 ++++++++++ 5 files changed, 349 insertions(+), 20 deletions(-) create mode 100644 src/jobSubmission.ts create mode 100644 tests/jobSubmission.tests.ts diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index f52db8f..fc3d72e 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -146,12 +146,20 @@ revertable. - Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. Size: S. -### 1.3 Humane metadata (D2) — `@mat3ra/workflow-designer` + job-designer - -- UUIDs on subworkflow cards and flowchart nodes move behind a copy-id icon with tooltip; - status chips ("idle") render only when the job has been submitted (`job.isInInitialStatus` - gates them — job-designer passes the flag; the workflow pane already receives - `adjustable={job.isInInitialStatus}` so thread one more boolean, e.g. `showUnitStatus`). +### 1.3 Humane metadata (D2) — `@mat3ra/wove` + job-designer + +> **Correction (2026-08-16, during implementation):** this was scoped to +> `@mat3ra/workflow-designer`. It is not there. The UUIDs and "idle" chips render in +> **`@mat3ra/wove`** — `components/common/CardHeader`, `components/units/UnitCard`, +> `components/workflows/WorkflowUnitCard` — which draws the subworkflow cards and flowchart +> nodes as MUI `Card`s with the id as `CardHeader` subheader and the status as a `Chip` in the +> title. Found by tracing the rendered DOM in the running job-designer demo. + +- UUIDs on subworkflow cards and flowchart nodes move behind cove's `CopyId`; status chips + render only when the job has been submitted (job-designer passes the flag; the workflow pane + already receives `adjustable={job.isInInitialStatus}`, so thread one more boolean, e.g. + `showUnitStatus`, through `workflow-designer` into wove). +- **Blocked on** the cove release (1.5) — `CopyId` and `StatusChip` must be published first. - Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. Size: M. diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 3240696..8e4884b 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -2,12 +2,21 @@ import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Alert from "@mui/material/Alert"; +import AlertTitle from "@mui/material/AlertTitle"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Tooltip from "@mui/material/Tooltip"; import lodash from "lodash"; import { mix } from "mixwith"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; +import { getSubmitBlockedReason } from "../jobSubmission"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; import DatasetTab from "./DatasetTab"; @@ -74,6 +83,48 @@ const getConditionalTabs = (config, conditionalMap, key) => Object.values(config).filter((tab) => conditionalMap[tab[key]] !== false); const createMessageTextTAPi18n = (key) => key; +/** + * Shown when the designer throws while rendering. + * + * The fallback used to be `
`: a render error produced a silently blank + * page, with nothing to report and no way back. Anything the reader can act on + * beats an empty screen, so this names what happened and offers a reload; the + * digest is there to be pasted into a bug report. + */ +function JobDesignerErrorCard({ error, resetErrorBoundary }) { + return ( + + + Reload designer + + } + > + This job could not be displayed + Something in the designer failed to render. Your saved job is unaffected — reloading + usually clears it. If it keeps happening, include this with a report: + + {error?.message ?? String(error)} + + + + ); +} + // TODO: resolve the problem with unit output update and make job component deep-comparable again class Job extends mix(React.Component).with( StatePropsCompareOnUpdateForJobMIxin, @@ -88,6 +139,7 @@ class Job extends mix(React.Component).with( entity: this.props.job, // make a copy to avoid modifying original object `parentJob` currentTab: this.defaultTab, isWorkflowLoading: false, + isTerminateConfirmationOpen: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -182,7 +234,11 @@ class Job extends mix(React.Component).with( this.shouldComponentUpdateForJob(nextProps, nextState) || this.shouldComponentUpdateFromComputableEntityMixin(nextProps, nextState) || this.state.currentTab !== nextState.currentTab || - this.state.isWorkflowLoading !== nextState.isWorkflowLoading + this.state.isWorkflowLoading !== nextState.isWorkflowLoading || + // Without this the confirmation never appears: the mixins below only + // consider the job entity, so a state change this component owns is + // invisible to them and the render is skipped. + this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen ); } @@ -272,18 +328,10 @@ class Job extends mix(React.Component).with( content: "Select dataset", onClick: this.openDatasetUploadsDialog, }, - { - isShown: Boolean(job.id && job.isInInitialStatus), - id: "select-submit", - content: "Submit", - onClick: this.props.onSubmit, - }, - { - isShown: Boolean(job.id && job.isInRunningStatus), - id: "select-terminate", - content: "Terminate", - onClick: this.props.onTerminate, - }, + // Submit and Terminate deliberately do NOT live here any more: they are + // the two actions the whole screen exists to reach, and a menu item that + // silently disappears when the job is not ready tells the reader nothing. + // They are header buttons now - see renderSubmitAction(). ]; // renders divider if some actions should be shown @@ -294,6 +342,94 @@ class Job extends mix(React.Component).with( return actions; }; + openTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: true }); + + closeTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: false }); + + confirmTerminate = () => { + this.closeTerminateConfirmation(); + this.props.onTerminate(); + }; + + /** + * Submit and Terminate as header buttons rather than dropdown items. + * + * A disabled Submit says what is missing instead of vanishing, which is what + * the dropdown did. Terminate asks first - it kills a running job, and it + * used to be a single unconfirmed click. + */ + renderSubmitAction() { + const job = this.state.entity; + const { editable, materials, onSubmit } = this.props; + + if (!editable) return null; + + if (job.isInRunningStatus) { + return ( + + ); + } + + if (!job.isInInitialStatus) return null; + + const blockerOptions = { + job, + materials: materials ?? [], + isUsingMaterials: this.isUsingMaterialsTab, + }; + const blockedReason = getSubmitBlockedReason(blockerOptions); + + return ( + + {/* span: MUI needs a non-disabled wrapper for the tooltip to fire. */} + + + + + ); + } + + renderTerminateConfirmation() { + const job = this.state.entity; + + return ( + + Terminate this job? + + + {job.name} is still running. Terminating stops it where it is; + results produced so far are kept, but the run cannot be resumed. + + + + + + + + ); + } + getSaveBtnProps() { const isDesignerLoading = this.props.isLoading || this.state.isWorkflowLoading; return { @@ -594,7 +730,7 @@ class Job extends mix(React.Component).with( const InjectedEntityHeader = getInjectedDeps().EntityHeaderComponent; return ( - }> + {InjectedEntityHeader ? ( + {this.renderSubmitAction()} {headerChildren ?? null} ) : ( @@ -643,9 +780,11 @@ class Job extends mix(React.Component).with( empty menu. */} {dropdownProps.isShown && } {this.props.editable && } + {this.renderSubmitAction()} {headerChildren ?? null} )} + {this.renderTerminateConfirmation()} {this.renderParentJob()} {this.renderErrors()} {this.renderWarnings()} diff --git a/src/components/WorkflowTab.tsx b/src/components/WorkflowTab.tsx index 0b8016c..5de1e03 100644 --- a/src/components/WorkflowTab.tsx +++ b/src/components/WorkflowTab.tsx @@ -131,6 +131,9 @@ export default function WorkflowTab({ workflowRenderGeneration={workflowRenderGeneration} isDescriptionEditable={isDescriptionEditable} jobProperties={jobProperties} + // The job has its own Compute tab; without this the same screen + // offers two of them and the reader has to guess which one runs. + hideComputeSubTab />
); diff --git a/src/jobSubmission.ts b/src/jobSubmission.ts new file mode 100644 index 0000000..f29724d --- /dev/null +++ b/src/jobSubmission.ts @@ -0,0 +1,84 @@ +/** + * What still stands between a job and being submitted. + * + * Submit used to be one item among several in a dropdown, shown or hidden by a + * single `job.id && job.isInInitialStatus` check. Hidden is the worst of the + * three states a control can be in: the reader cannot act on it and is not told + * why. Promoting Submit to the header means it is always visible, which in turn + * means it has to be able to explain itself when it is not available. + * + * Kept as a pure function of the job so it can be unit-tested and reused: the + * readiness rail and the submit preflight (SOF-8023 phases 2.1 and 2.4) need the + * same answer, and the three must never disagree about whether a job is ready. + */ + +/** Minimal shape this module needs; the real entity is jode's `Job`. */ +export interface SubmittableJob { + id?: string; + isInInitialStatus?: boolean; + isInRunningStatus?: boolean; + workflow?: { + subworkflows?: unknown[]; + isUsingDataset?: boolean; + }; + compute?: { cluster?: { fqdn?: string } } | null; +} + +export interface SubmitBlockersOptions { + job: SubmittableJob; + /** Materials currently attached; empty for dataset-driven jobs. */ + materials?: unknown[]; + /** False for dataset jobs, where materials are not the input. */ + isUsingMaterials?: boolean; +} + +/** + * Reasons the job cannot be submitted, in the order a reader would fix them. + * Empty means ready. Each string is shown to the reader verbatim, so it names + * what to do, not what is wrong internally. + */ +export function getSubmitBlockers({ + job, + materials = [], + isUsingMaterials = true, +}: SubmitBlockersOptions): string[] { + const blockers: string[] = []; + + if (isUsingMaterials && materials.length === 0) { + blockers.push("Select a material"); + } + + if (!job.workflow?.subworkflows?.length) { + blockers.push("Select a workflow"); + } + + if (!job.compute?.cluster?.fqdn) { + blockers.push("Configure compute"); + } + + // Last, because it is the one the reader fixes by pressing the button next + // to Submit rather than by going somewhere else. + if (!job.id) { + blockers.push("Save the job"); + } + + return blockers; +} + +export function isJobSubmittable(options: SubmitBlockersOptions): boolean { + return Boolean(options.job.isInInitialStatus) && getSubmitBlockers(options).length === 0; +} + +/** + * One line for a disabled Submit button. Names the first thing to fix and how + * much else is waiting, rather than listing everything in a tooltip nobody + * reads to the end. + */ +export function getSubmitBlockedReason(options: SubmitBlockersOptions): string | null { + const blockers = getSubmitBlockers(options); + + if (blockers.length === 0) return null; + if (blockers.length === 1) return blockers[0]; + + return `${blockers[0]} (+${blockers.length - 1} more)`; +} diff --git a/tests/jobSubmission.tests.ts b/tests/jobSubmission.tests.ts new file mode 100644 index 0000000..efa47bc --- /dev/null +++ b/tests/jobSubmission.tests.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + getSubmitBlockedReason, + getSubmitBlockers, + isJobSubmittable, + type SubmittableJob, +} from "../src/jobSubmission"; + +const readyJob: SubmittableJob = { + id: "job-1", + isInInitialStatus: true, + workflow: { subworkflows: [{}] }, + compute: { cluster: { fqdn: "cluster-1.mat3ra.com" } }, +}; + +describe("getSubmitBlockers", () => { + it("finds nothing wrong with a ready job", () => { + assert.deepEqual(getSubmitBlockers({ job: readyJob, materials: [{}] }), []); + }); + + it("asks for a material only when the job uses materials", () => { + assert.deepEqual(getSubmitBlockers({ job: readyJob, materials: [] }), [ + "Select a material", + ]); + // Dataset-driven jobs take a dataset, not a material. + assert.deepEqual( + getSubmitBlockers({ job: readyJob, materials: [], isUsingMaterials: false }), + [], + ); + }); + + it("asks for a workflow when there are no subworkflows", () => { + const job = { ...readyJob, workflow: { subworkflows: [] } }; + assert.deepEqual(getSubmitBlockers({ job, materials: [{}] }), ["Select a workflow"]); + }); + + it("asks for compute when no cluster is chosen", () => { + assert.deepEqual( + getSubmitBlockers({ job: { ...readyJob, compute: null }, materials: [{}] }), + ["Configure compute"], + ); + assert.deepEqual( + getSubmitBlockers({ job: { ...readyJob, compute: { cluster: {} } }, materials: [{}] }), + ["Configure compute"], + ); + }); + + it("asks for a save when the job has never been persisted", () => { + const { id, ...unsaved } = readyJob; + assert.deepEqual(getSubmitBlockers({ job: unsaved, materials: [{}] }), ["Save the job"]); + }); + + it("orders blockers the way a reader would work through them", () => { + assert.deepEqual( + getSubmitBlockers({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }), + ["Select a material", "Select a workflow", "Configure compute", "Save the job"], + ); + }); +}); + +describe("isJobSubmittable", () => { + it("is true only for an unblocked job in its initial status", () => { + assert.equal(isJobSubmittable({ job: readyJob, materials: [{}] }), true); + assert.equal(isJobSubmittable({ job: readyJob, materials: [] }), false); + assert.equal( + isJobSubmittable({ job: { ...readyJob, isInInitialStatus: false }, materials: [{}] }), + false, + ); + }); +}); + +describe("getSubmitBlockedReason", () => { + it("is null when there is nothing to report", () => { + assert.equal(getSubmitBlockedReason({ job: readyJob, materials: [{}] }), null); + }); + + it("names the single blocker", () => { + assert.equal(getSubmitBlockedReason({ job: readyJob, materials: [] }), "Select a material"); + }); + + it("names the first blocker and counts the rest", () => { + assert.equal( + getSubmitBlockedReason({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }), + "Select a material (+3 more)", + ); + }); +}); From a405bd414f0da00b2c9bbf9d04aafd7645957fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:29:51 +0000 Subject: [PATCH 08/33] build: regenerate dist for phase 1 changes dist/ is tracked here and dist/jobSubmission.js was missing entirely, which Job.js imports. The husky pre-commit hook regenerates and stages it automatically, but only once hooks are installed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 24 ++++---- dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 92 +++++++++++++++++++++++----- dist/components/WorkflowTab.d.ts.map | 2 +- dist/components/WorkflowTab.js | 5 +- dist/jobSubmission.d.ts | 49 +++++++++++++++ dist/jobSubmission.d.ts.map | 1 + dist/jobSubmission.js | 53 ++++++++++++++++ 8 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 dist/jobSubmission.d.ts create mode 100644 dist/jobSubmission.d.ts.map create mode 100644 dist/jobSubmission.js diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index 69e57c5..acb93d9 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -39,13 +39,19 @@ declare class Job { content: string; onClick: () => void; icon?: undefined; - } | { - isShown: boolean; - id: string; - content: string; - onClick: any; - icon?: undefined; })[]; + openTerminateConfirmation: () => any; + closeTerminateConfirmation: () => any; + confirmTerminate: () => void; + /** + * Submit and Terminate as header buttons rather than dropdown items. + * + * A disabled Submit says what is missing instead of vanishing, which is what + * the dropdown did. Terminate asks first - it kills a running job, and it + * used to be a single unconfirmed click. + */ + renderSubmitAction(): React.JSX.Element; + renderTerminateConfirmation(): React.JSX.Element; getSaveBtnProps(): { id: string; buttonConfigs: { @@ -72,12 +78,6 @@ declare class Job { content: string; onClick: () => void; icon?: undefined; - } | { - isShown: boolean; - id: string; - content: string; - onClick: any; - icon?: undefined; })[]; buttonContent: string; }; diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index 7d827aa..eaa435f 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AA6EA;IAMI,wBAWC;IATG,WAKC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAIC;IAdD;;OAEG;IACH,mBAEC;IAWD,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAEC;IAED,yCAOC;IAED,6BAEC;IAED,2DAOC;IAED,kBAAmB,YAAO,UAIxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAIlB;IAEF,eAAgB,WAAM,UAIpB;IAEF,2BAKE;IAEF,qCAgBC;IAED;;;;;;;;;;;;;;;;;;SAmDE;IAEF;;;;;;;;;;MA8BC;IAED;;;;;;;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAqQC;CACJ;kBA7vBiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAgIA;IAMI,wBAYC;IAVG,WAMC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAIC;IAdD;;OAEG;IACH,mBAEC;IAWD,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAEC;IAED,yCAOC;IAED,6BAEC;IAED,2DAWC;IAED,kBAAmB,YAAO,UAIxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAIlB;IAEF,eAAgB,WAAM,UAIpB;IAEF,2BAKE;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA8BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAwQC;CACJ;kBAh4BiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index 9bdae50..2d357d4 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -3,12 +3,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Alert from "@mui/material/Alert"; +import AlertTitle from "@mui/material/AlertTitle"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Tooltip from "@mui/material/Tooltip"; import lodash from "lodash"; import { mix } from "mixwith"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; +import { getSubmitBlockedReason } from "../jobSubmission"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; import DatasetTab from "./DatasetTab"; @@ -68,6 +77,27 @@ const getFileUtils = () => { const triggerChartsResize = () => { }; const getConditionalTabs = (config, conditionalMap, key) => Object.values(config).filter((tab) => conditionalMap[tab[key]] !== false); const createMessageTextTAPi18n = (key) => key; +/** + * Shown when the designer throws while rendering. + * + * The fallback used to be `
`: a render error produced a silently blank + * page, with nothing to report and no way back. Anything the reader can act on + * beats an empty screen, so this names what happened and offers a reload; the + * digest is there to be pasted into a bug report. + */ +function JobDesignerErrorCard({ error, resetErrorBoundary }) { + var _a; + return (_jsx(Box, { p: 3, children: _jsxs(Alert, { severity: "error", action: _jsx(Button, { color: "inherit", size: "small", onClick: resetErrorBoundary, children: "Reload designer" }), children: [_jsx(AlertTitle, { children: "This job could not be displayed" }), "Something in the designer failed to render. Your saved job is unaffected \u2014 reloading usually clears it. If it keeps happening, include this with a report:", _jsx(Box, { component: "code", sx: { + display: "block", + mt: 1, + p: 1, + borderRadius: 1, + bgcolor: "action.hover", + fontSize: "0.75rem", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }, children: (_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : String(error) })] }) })); +} // TODO: resolve the problem with unit output update and make job component deep-comparable again class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin, StatefulEntityMixin, DescriptionUpdateMixin, ComputableEntityMixin) { constructor(props) { @@ -130,18 +160,10 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin content: "Select dataset", onClick: this.openDatasetUploadsDialog, }, - { - isShown: Boolean(job.id && job.isInInitialStatus), - id: "select-submit", - content: "Submit", - onClick: this.props.onSubmit, - }, - { - isShown: Boolean(job.id && job.isInRunningStatus), - id: "select-terminate", - content: "Terminate", - onClick: this.props.onTerminate, - }, + // Submit and Terminate deliberately do NOT live here any more: they are + // the two actions the whole screen exists to reach, and a menu item that + // silently disappears when the job is not ready tells the reader nothing. + // They are header buttons now - see renderSubmitAction(). ]; // renders divider if some actions should be shown if (actions.some((item) => item.isShown)) { @@ -149,6 +171,12 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin } return actions; }; + this.openTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: true }); + this.closeTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: false }); + this.confirmTerminate = () => { + this.closeTerminateConfirmation(); + this.props.onTerminate(); + }; this.onSelectParentJobSubmit = async (ids) => { // Entity DAO key is the string "Job"; do not use this React class's .name - // build tools may mangle/shorten class names during minification @@ -287,6 +315,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin entity: this.props.job, // make a copy to avoid modifying original object `parentJob` currentTab: this.defaultTab, isWorkflowLoading: false, + isTerminateConfirmationOpen: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -371,13 +400,46 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin return (this.shouldComponentUpdateForJob(nextProps, nextState) || this.shouldComponentUpdateFromComputableEntityMixin(nextProps, nextState) || this.state.currentTab !== nextState.currentTab || - this.state.isWorkflowLoading !== nextState.isWorkflowLoading); + this.state.isWorkflowLoading !== nextState.isWorkflowLoading || + // Without this the confirmation never appears: the mixins below only + // consider the job entity, so a state change this component owns is + // invisible to them and the render is skipped. + this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen); } renderParentJob() { var _a, _b; const parentJob = (_b = (_a = this.state.entity).getParentJobClient) === null || _b === void 0 ? void 0 : _b.call(_a); return parentJob ? (_jsx(Alert, { severity: "info", onClose: this.props.editable ? this.onParentRemove : undefined, children: _jsxs("div", { className: "search-pill-selected", children: ["Parent job:", " ", _jsx("b", { children: _jsx("a", { href: "", onClick: parentJob.open, children: parentJob.name }) }), " ", "from\u00A0", _jsx("b", { children: parentJob._project.slug }), " project"] }) })) : null; } + /** + * Submit and Terminate as header buttons rather than dropdown items. + * + * A disabled Submit says what is missing instead of vanishing, which is what + * the dropdown did. Terminate asks first - it kills a running job, and it + * used to be a single unconfirmed click. + */ + renderSubmitAction() { + const job = this.state.entity; + const { editable, materials, onSubmit } = this.props; + if (!editable) + return null; + if (job.isInRunningStatus) { + return (_jsx(Button, { id: "job-terminate-button", variant: "outlined", color: "error", size: "small", onClick: this.openTerminateConfirmation, children: "Terminate" })); + } + if (!job.isInInitialStatus) + return null; + const blockerOptions = { + job, + materials: materials !== null && materials !== void 0 ? materials : [], + isUsingMaterials: this.isUsingMaterialsTab, + }; + const blockedReason = getSubmitBlockedReason(blockerOptions); + return (_jsx(Tooltip, { title: blockedReason !== null && blockedReason !== void 0 ? blockedReason : "", children: _jsx("span", { children: _jsx(Button, { id: "job-submit-button", variant: "contained", size: "small", disabled: Boolean(blockedReason), onClick: onSubmit, children: "Submit" }) }) })); + } + renderTerminateConfirmation() { + const job = this.state.entity; + return (_jsxs(Dialog, { open: Boolean(this.state.isTerminateConfirmationOpen), onClose: this.closeTerminateConfirmation, children: [_jsx(DialogTitle, { children: "Terminate this job?" }), _jsx(DialogContent, { children: _jsxs(DialogContentText, { children: [_jsx("b", { children: job.name }), " is still running. Terminating stops it where it is; results produced so far are kept, but the run cannot be resumed."] }) }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: this.closeTerminateConfirmation, children: "Keep running" }), _jsx(Button, { color: "error", variant: "contained", onClick: this.confirmTerminate, children: "Terminate" })] })] })); + } getSaveBtnProps() { const isDesignerLoading = this.props.isLoading || this.state.isWorkflowLoading; return { @@ -468,7 +530,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // Save & Exit split button) for production parity; standalone falls back to a // minimal header built on cove's EntityHeader below. const InjectedEntityHeader = getInjectedDeps().EntityHeaderComponent; - return (_jsxs(ErrorBoundary, { fallback: _jsx("div", {}), children: [InjectedEntityHeader ? (_jsx(InjectedEntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, description: job.description, icon: "entities.job", iconCls: `text-${job.statusCls}`, id: "job-designer-header", saveBtnProps: { + return (_jsxs(ErrorBoundary, { FallbackComponent: JobDesignerErrorCard, children: [InjectedEntityHeader ? (_jsxs(InjectedEntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, description: job.description, icon: "entities.job", iconCls: `text-${job.statusCls}`, id: "job-designer-header", saveBtnProps: { isShown: Boolean(this.props.editable), isLoading: isDesignerLoading, // Read `this.state.entity` at call time (not a render-time `job` @@ -476,7 +538,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. onSave: (omitRedirect) => this._resetStateEntityAndUpdateParents(this.state.entity, () => this.props.onSave(omitRedirect)), - }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: headerChildren !== null && headerChildren !== void 0 ? headerChildren : null })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); + }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); } } export default Job; diff --git a/dist/components/WorkflowTab.d.ts.map b/dist/components/WorkflowTab.d.ts.map index 2c96ce7..89ed1ef 100644 --- a/dist/components/WorkflowTab.d.ts.map +++ b/dist/components/WorkflowTab.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"WorkflowTab.d.ts","sourceRoot":"","sources":["../../src/components/WorkflowTab.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,QAAQ,IAAI,YAAY,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,KAAK,EAA6B,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAG1F,OAAO,KAAsB,MAAM,OAAO,CAAC;AAE3C,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAC/B,aAAa,EACX,UAAU,GACV,YAAY,GACZ,WAAW,GACX,gBAAgB,GAChB,kBAAkB,GAClB,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,eAAe,GACf,WAAW,GACX,oBAAoB,GACpB,eAAe,GACf,cAAc,GACd,0BAA0B,CAC/B,GAAG;IACA,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,gBAAgB,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,GAAG,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,wBAAwB,CAAC,EAAE,aAAa,CAAC,0BAA0B,CAAC,CAAC;IACrE,YAAY,EAAE,GAAG,EAAE,CAAC;IACpB,qBAAqB,EAAE,OAAO,CAAC;IAC/B,qBAAqB,EAAE,OAAO,CAAC;CAClC,CAAC;AAEF,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAChC,EAAE,EACF,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,UAAiB,EACjB,SAAS,EACT,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,SAAS,EACT,OAAO,EACP,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,qBAAqB,EACrB,YAAY,EACZ,OAAO,EACP,OAAO,EACP,aAAa,EACb,SAAS,EACT,kBAAkB,EAClB,aAAa,EACb,YAAoB,EACpB,qBAAqB,EACrB,wBAAwB,GAC3B,EAAE,gBAAgB,qBAqElB"} \ No newline at end of file +{"version":3,"file":"WorkflowTab.d.ts","sourceRoot":"","sources":["../../src/components/WorkflowTab.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,QAAQ,IAAI,YAAY,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,KAAK,EAA6B,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAG1F,OAAO,KAAsB,MAAM,OAAO,CAAC;AAE3C,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAC/B,aAAa,EACX,UAAU,GACV,YAAY,GACZ,WAAW,GACX,gBAAgB,GAChB,kBAAkB,GAClB,uBAAuB,GACvB,SAAS,GACT,SAAS,GACT,eAAe,GACf,WAAW,GACX,oBAAoB,GACpB,eAAe,GACf,cAAc,GACd,0BAA0B,CAC/B,GAAG;IACA,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,gBAAgB,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,GAAG,EAAE,CAAC;IACtB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,wBAAwB,CAAC,EAAE,aAAa,CAAC,0BAA0B,CAAC,CAAC;IACrE,YAAY,EAAE,GAAG,EAAE,CAAC;IACpB,qBAAqB,EAAE,OAAO,CAAC;IAC/B,qBAAqB,EAAE,OAAO,CAAC;CAClC,CAAC;AAEF,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAChC,EAAE,EACF,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,UAAiB,EACjB,SAAS,EACT,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,SAAS,EACT,OAAO,EACP,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,qBAAqB,EACrB,YAAY,EACZ,OAAO,EACP,OAAO,EACP,aAAa,EACb,SAAS,EACT,kBAAkB,EAClB,aAAa,EACb,YAAoB,EACpB,qBAAqB,EACrB,wBAAwB,GAC3B,EAAE,gBAAgB,qBAwElB"} \ No newline at end of file diff --git a/dist/components/WorkflowTab.js b/dist/components/WorkflowTab.js index 869741c..83dfedb 100644 --- a/dist/components/WorkflowTab.js +++ b/dist/components/WorkflowTab.js @@ -19,5 +19,8 @@ export default function WorkflowTab({ id, className, role, workflow, adjustable } onWorkflowUpdate(workflow); }, [workflow, onWorkflowUpdate]); - return (_jsx("div", { className: setClass(className), id: id, role: role, children: _jsx(Workflow, { workflow: workflow, showHeader: true, editable: false, adjustable: adjustable, profile: profile, publicAccount: publicAccount, onSubworkflowUnitUpdate: onSubworkflowUnitUpdate, onUnitUpdate: onUnitUpdate, onIsMultiMaterialChanged: onIsMultiMaterialChanged, materials: materials, materialsSet: materialsSet, materialsIndex: materialsIndex, jobHasParent: jobHasParent, onMaterialSwitch: onMaterialSwitch, onUpdate: onWorkflowUpdate, isLoading: isLoading, iconCls: iconCls, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: dialogs, templates: templates, createMetaProperty: createMetaProperty, onRender: onJobRender, renderAtJobLevel: true, workflowRenderGeneration: workflowRenderGeneration, isDescriptionEditable: isDescriptionEditable, jobProperties: jobProperties }) })); + return (_jsx("div", { className: setClass(className), id: id, role: role, children: _jsx(Workflow, { workflow: workflow, showHeader: true, editable: false, adjustable: adjustable, profile: profile, publicAccount: publicAccount, onSubworkflowUnitUpdate: onSubworkflowUnitUpdate, onUnitUpdate: onUnitUpdate, onIsMultiMaterialChanged: onIsMultiMaterialChanged, materials: materials, materialsSet: materialsSet, materialsIndex: materialsIndex, jobHasParent: jobHasParent, onMaterialSwitch: onMaterialSwitch, onUpdate: onWorkflowUpdate, isLoading: isLoading, iconCls: iconCls, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: dialogs, templates: templates, createMetaProperty: createMetaProperty, onRender: onJobRender, renderAtJobLevel: true, workflowRenderGeneration: workflowRenderGeneration, isDescriptionEditable: isDescriptionEditable, jobProperties: jobProperties, + // The job has its own Compute tab; without this the same screen + // offers two of them and the reader has to guess which one runs. + hideComputeSubTab: true }) })); } diff --git a/dist/jobSubmission.d.ts b/dist/jobSubmission.d.ts new file mode 100644 index 0000000..6a36430 --- /dev/null +++ b/dist/jobSubmission.d.ts @@ -0,0 +1,49 @@ +/** + * What still stands between a job and being submitted. + * + * Submit used to be one item among several in a dropdown, shown or hidden by a + * single `job.id && job.isInInitialStatus` check. Hidden is the worst of the + * three states a control can be in: the reader cannot act on it and is not told + * why. Promoting Submit to the header means it is always visible, which in turn + * means it has to be able to explain itself when it is not available. + * + * Kept as a pure function of the job so it can be unit-tested and reused: the + * readiness rail and the submit preflight (SOF-8023 phases 2.1 and 2.4) need the + * same answer, and the three must never disagree about whether a job is ready. + */ +/** Minimal shape this module needs; the real entity is jode's `Job`. */ +export interface SubmittableJob { + id?: string; + isInInitialStatus?: boolean; + isInRunningStatus?: boolean; + workflow?: { + subworkflows?: unknown[]; + isUsingDataset?: boolean; + }; + compute?: { + cluster?: { + fqdn?: string; + }; + } | null; +} +export interface SubmitBlockersOptions { + job: SubmittableJob; + /** Materials currently attached; empty for dataset-driven jobs. */ + materials?: unknown[]; + /** False for dataset jobs, where materials are not the input. */ + isUsingMaterials?: boolean; +} +/** + * Reasons the job cannot be submitted, in the order a reader would fix them. + * Empty means ready. Each string is shown to the reader verbatim, so it names + * what to do, not what is wrong internally. + */ +export declare function getSubmitBlockers({ job, materials, isUsingMaterials, }: SubmitBlockersOptions): string[]; +export declare function isJobSubmittable(options: SubmitBlockersOptions): boolean; +/** + * One line for a disabled Submit button. Names the first thing to fix and how + * much else is waiting, rather than listing everything in a tooltip nobody + * reads to the end. + */ +export declare function getSubmitBlockedReason(options: SubmitBlockersOptions): string | null; +//# sourceMappingURL=jobSubmission.d.ts.map \ No newline at end of file diff --git a/dist/jobSubmission.d.ts.map b/dist/jobSubmission.d.ts.map new file mode 100644 index 0000000..2249fef --- /dev/null +++ b/dist/jobSubmission.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"jobSubmission.d.ts","sourceRoot":"","sources":["../src/jobSubmission.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,EAAE;QACP,YAAY,CAAC,EAAE,OAAO,EAAE,CAAC;QACzB,cAAc,CAAC,EAAE,OAAO,CAAC;KAC5B,CAAC;IACF,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,qBAAqB;IAClC,GAAG,EAAE,cAAc,CAAC;IACpB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,EAAE,CAAC;IACtB,iEAAiE;IACjE,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAC9B,GAAG,EACH,SAAc,EACd,gBAAuB,GAC1B,EAAE,qBAAqB,GAAG,MAAM,EAAE,CAsBlC;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAExE;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,qBAAqB,GAAG,MAAM,GAAG,IAAI,CAOpF"} \ No newline at end of file diff --git a/dist/jobSubmission.js b/dist/jobSubmission.js new file mode 100644 index 0000000..91dcd89 --- /dev/null +++ b/dist/jobSubmission.js @@ -0,0 +1,53 @@ +/** + * What still stands between a job and being submitted. + * + * Submit used to be one item among several in a dropdown, shown or hidden by a + * single `job.id && job.isInInitialStatus` check. Hidden is the worst of the + * three states a control can be in: the reader cannot act on it and is not told + * why. Promoting Submit to the header means it is always visible, which in turn + * means it has to be able to explain itself when it is not available. + * + * Kept as a pure function of the job so it can be unit-tested and reused: the + * readiness rail and the submit preflight (SOF-8023 phases 2.1 and 2.4) need the + * same answer, and the three must never disagree about whether a job is ready. + */ +/** + * Reasons the job cannot be submitted, in the order a reader would fix them. + * Empty means ready. Each string is shown to the reader verbatim, so it names + * what to do, not what is wrong internally. + */ +export function getSubmitBlockers({ job, materials = [], isUsingMaterials = true, }) { + var _a, _b, _c, _d; + const blockers = []; + if (isUsingMaterials && materials.length === 0) { + blockers.push("Select a material"); + } + if (!((_b = (_a = job.workflow) === null || _a === void 0 ? void 0 : _a.subworkflows) === null || _b === void 0 ? void 0 : _b.length)) { + blockers.push("Select a workflow"); + } + if (!((_d = (_c = job.compute) === null || _c === void 0 ? void 0 : _c.cluster) === null || _d === void 0 ? void 0 : _d.fqdn)) { + blockers.push("Configure compute"); + } + // Last, because it is the one the reader fixes by pressing the button next + // to Submit rather than by going somewhere else. + if (!job.id) { + blockers.push("Save the job"); + } + return blockers; +} +export function isJobSubmittable(options) { + return Boolean(options.job.isInInitialStatus) && getSubmitBlockers(options).length === 0; +} +/** + * One line for a disabled Submit button. Names the first thing to fix and how + * much else is waiting, rather than listing everything in a tooltip nobody + * reads to the end. + */ +export function getSubmitBlockedReason(options) { + const blockers = getSubmitBlockers(options); + if (blockers.length === 0) + return null; + if (blockers.length === 1) + return blockers[0]; + return `${blockers[0]} (+${blockers.length - 1} more)`; +} From 246a42e2c07b92e54fa37fc5da3dfdd3e32185fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 20:30:35 +0000 Subject: [PATCH 09/33] docs: record the committed-dist convention in AGENTS.md All @mat3ra/* TypeScript packages track dist/ in git and ship it, so a src change committed without the matching dist leaves consumers on the old code - and a brand-new module is worse than stale: its dist file is absent entirely, so the emitted code imports something that does not exist. The husky pre-commit hook covers this, but only once hooks are installed, and most of these repos have no 'prepare: husky install' script - so a fresh clone commits without it. All four SOF-8023 PRs hit exactly this. Divergence from mat3ra/agents@c75a1b8 - worth upstreaming. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f1e3054..d602c75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,39 @@ Use linting for autoformatting the codebase. Consider language-specific tools an Use pre-commit to run linters and formatters automatically. +### 1.7.1. Committed build output (`dist/`) — TypeScript packages + +The `@mat3ra/*` TypeScript packages **track `dist/` in git** and ship it (`"files": ["/dist", +"/src"]`, `"main": "dist/exports.js"`). Committing a `src/` change without the matching +`dist/` leaves the repository — and any consumer installing from it — running the old code. +A brand-new module is the dangerous case: its `dist/` file is absent entirely, so the +emitted code imports something that does not exist, and the package throws at runtime +rather than merely behaving as the old version. + +The husky `pre-commit` hook already handles this: + +```sh +npm run transpile +git add dist/ +``` + +**It only fires once hooks are installed.** Repos whose `package.json` has no +`"prepare": "husky install"` (most of them — `cove` is an exception) leave a fresh clone +with no active hooks, so `npm install` alone does not arm it and commits go out with a +stale `dist/`. Do not rely on `prepublishOnly` to cover it either: some packages have no +such script, and the tracked `dist/` goes stale regardless. + +So, before committing in any package that tracks `dist/`: + +```sh +npm run transpile # or: npm run build +git add dist/ +``` + +Verify rather than assume — `git ls-tree -r origin/main --name-only | grep -c '^dist/'` +says whether the repo tracks build output, and `git status --short` after transpiling says +whether your change reached it. + ### 1.8. GitHub Actions Use GitHub Actions to run tests and linters automatically. From dfd250118248b7fb564ce69688f435df7847cb0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 21:46:27 +0000 Subject: [PATCH 10/33] chore: arm the pre-commit hook so dist stays in sync The hook that regenerates dist has never run: husky was not a dependency and no prepare script installed it, so a fresh clone had .husky/pre-commit sitting inert. That is why this and three sibling packages landed src changes with a stale or entirely missing dist - here, dist/jobSubmission.js was absent while Job.js imported it. Adds husky + 'prepare: husky install' (matching cove, the one repo where this works), and drops 'npx lint-staged' from the hook: lint-staged is neither a dependency nor configured here, so arming the hook with that line would have failed and blocked every commit. Linting is enforced in CI regardless. Verified in ive: staging only a src file produced a commit that included the regenerated dist output. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- .husky/pre-commit | 5 ++++- package.json | 44 +++++++++++++++++++++++--------------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 76031ec..5325f24 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,9 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged +# dist/ is tracked and shipped ("files": ["/dist", "/src"]), so a src change +# committed without it leaves consumers on the old code - and a brand-new module +# is worse than stale, since its dist file is absent entirely and the emitted +# code imports something that does not exist. npm run transpile git add dist/ diff --git a/package.json b/package.json index 92c8864..3d8af9e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "build": "tsc && npm run copy-css", "copy-css": "mkdir -p dist/stylesheets && cp src/stylesheets/* dist/stylesheets/ 2>/dev/null || true", + "prepare": "husky install", "transpile": "tsc", "test": "node --import tsx --test tests/*.tests.ts", "dev": "vite --port 3003", @@ -55,11 +56,16 @@ }, "license": "Apache-2.0", "devDependencies": { - "@mat3ra/cove": "2026.7.28-0", - "@mat3ra/wave.js": "2026.7.18-1", + "@babel/core": "^7.24.3", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/preset-env": "^7.24.3", + "@babel/preset-react": "^7.24.1", + "@babel/preset-typescript": "^7.24.1", + "@exabyte-io/eslint-config": "^2025.1.15-0", "@mat3ra/ade": "2026.5.29-0", "@mat3ra/ave": "2026.7.21-0", "@mat3ra/code": "2026.5.27-0", + "@mat3ra/cove": "2026.7.28-0", "@mat3ra/esse": "2026.7.14-0", "@mat3ra/ive": "2026.7.18-0", "@mat3ra/jode": "2026.7.11-0", @@ -71,6 +77,7 @@ "@mat3ra/standata": "2026.7.13-0", "@mat3ra/tsconfig": "^2024.6.3-0", "@mat3ra/utils": "2026.7.9-0", + "@mat3ra/wave.js": "2026.7.18-1", "@mat3ra/wode": "2026.7.9-0", "@mat3ra/workflow-designer": "2026.7.21-1", "@mat3ra/wove": "2026.7.21-0", @@ -84,26 +91,9 @@ "@types/node": "^20.11.30", "@types/react": "^17.0.2", "@types/react-dom": "^17.0.2", - "@vitejs/plugin-react": "^4.3.4", - "lodash": "^4.17.4", - "mathjs": "^3.9.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "reactflow": "^11.7.2", - "tsx": "^4.22.4", - "typescript": "^5.6.6", - "underscore": "^1.8.3", - "underscore.string": "^3.3.4", - "vite": "^6.0.7", - "vite-plugin-node-polyfills": "^0.25.0", - "@babel/core": "^7.24.3", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.24.3", - "@babel/preset-react": "^7.24.1", - "@babel/preset-typescript": "^7.24.1", - "@exabyte-io/eslint-config": "^2025.1.15-0", "@typescript-eslint/eslint-plugin": "^5.9.1", "@typescript-eslint/parser": "^5.9.1", + "@vitejs/plugin-react": "^4.3.4", "babel-eslint": "^10.1.0", "eslint": "^7.32.0", "eslint-config-airbnb": "^19.0.2", @@ -119,6 +109,18 @@ "eslint-plugin-react": "^7.30.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-simple-import-sort": "^7.0.0", - "prettier": "2.5.1" + "husky": "^7.0.4", + "lodash": "^4.17.4", + "mathjs": "^3.9.0", + "prettier": "2.5.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "reactflow": "^11.7.2", + "tsx": "^4.22.4", + "typescript": "^5.6.6", + "underscore": "^1.8.3", + "underscore.string": "^3.3.4", + "vite": "^6.0.7", + "vite-plugin-node-polyfills": "^0.25.0" } } From 4769bb5717c466c976a852dd7868d500f01a16e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 21:48:33 +0000 Subject: [PATCH 11/33] docs: split the plan per phase; move phase 1 to review/ plan/README.md asks that a document move when its status changes, and that moving it is part of doing the work. Phase 1 is built and on branches, but it sat inside a single document spanning all three phases, so it could not move. Split per phase - the same shape SOF-8024 adopted, and for the same reason. The phase 1 document records what actually shipped and where it diverged: item 1.3 landed in wove rather than workflow-designer, and shipped as the 'Developer info' toggle SOF-8024 specified rather than the CopyId approach this plan proposed; the cove palette defects were worse than audited; and the build output was never being committed at all, which was an unplanned prerequisite. Also records the parallel SOF-8024 effort in the overview, with the three places the two plans overlap and which document is authoritative for each. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...026-08-16-Job-Designer-Phase-1-De-noise.md | 136 ++++++++++++++++++ ...08-16-Job-Designer-Guided-Designer-Plan.md | 100 +++---------- 2 files changed, 157 insertions(+), 79 deletions(-) create mode 100644 plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md diff --git a/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md new file mode 100644 index 0000000..6340e16 --- /dev/null +++ b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md @@ -0,0 +1,136 @@ +# Job Designer Phase 1 — De-noise + +- **Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +- **Parent:** [../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md) + (overview, current state, cove design-language audit, cross-cutting concerns). +- **Status:** built and on branches — every item below is implemented and in review. + Nothing merged yet, so this document sits in `review/` rather than `implemented/`. +- **Created:** 2026-08-16 · **Updated:** 2026-08-16 + +## Status — what shipped + +| Item | Where | PR | +|------|-------|----| +| 1.1 Progressive validation | `@mat3ra/ive` | [ive#6](https://github.com/mat3ra/ive/pull/6) | +| 1.2 Single Compute tab | `@mat3ra/workflow-designer` + job-designer | [workflow-designer#13](https://github.com/mat3ra/workflow-designer/pull/13), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 1.3 Humane metadata | `@mat3ra/wove` + `@mat3ra/workflow-designer` | [wove#11](https://github.com/mat3ra/wove/pull/11), [workflow-designer#14](https://github.com/mat3ra/workflow-designer/pull/14) | +| 1.4 First-class Submit | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 1.5 Design-language groundwork | `@mat3ra/cove` | [cove#97](https://github.com/mat3ra/cove/pull/97) | +| 1.6 Visible error state | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | + +Measured in the standalone demo with all package changes linked together: required-field +errors on a fresh Compute tab **4 → 0**; things labelled "Compute" on the Workflow tab +**2 → 1**; flowchart ids visible **8 → 0**; Submit moved from a dropdown item that +disappeared when unavailable to a header button that names what is missing. + +### Divergences from the plan as written + +- **1.3 landed in `@mat3ra/wove`, not `@mat3ra/workflow-designer`.** The ids and status + chips render in wove's `components/common/CardHeader` (used by `UnitCard` and + `WorkflowUnitCard`), traced from the rendered DOM. The plan had assigned it to + workflow-designer. +- **1.3's mechanism changed twice.** The plan proposed folding ids behind cove's `CopyId`. + It shipped instead as *hidden by default* with a **"Developer info"** toggle — the + approach the parallel SOF-8024 effort had specified (its items 1.1 and 1.5), adopted so + the two projects do not answer the same question differently. And it reaches wove through + a new `WoveDisplayOptionsProvider` context rather than props, because the flowchart cards + sit behind reactflow node data (`UnitsFlowchartContainer → UnitsFlowchart → node.data → + UnitNode → UnitCard`) where prop-drilling is not practical. +- **The cove defects were worse than audited.** `paletteDark` was not "identical to light" + but *missing* `background` / `text` / `action` / `border` / `icon` / `unitTypes` + entirely, so those read `undefined` in dark mode. Status `contrastText` values were + unusable (white on neon green at 1.7:1; `rgba(0, 0, 0, 0.23)` on red). Fixed with values + computed and asserted by `tests/palette.tests.ts`, not chosen by eye. +- **An unplanned prerequisite: the build output was never being committed.** `dist/` is + tracked and shipped by these packages, but the husky `pre-commit` hook that regenerates + it had never run — husky was not a dependency and no `prepare` script installed it. Four + packages had landed src changes with stale or entirely absent `dist`, including new + modules whose emitted code imported files that were never built. Fixed per repo, and + recorded in `AGENTS.md` §1.7.1. +- **1.4 needed a `shouldComponentUpdate` change** that the plan did not anticipate: the + component's mixins only consider the job entity, so the terminate confirmation could + never have rendered without it. + +### Still open + +- Nothing from Phase 1 is unimplemented. The remaining work is review and the release + order recorded in the parent plan (cove → wove → ive / workflow-designer → job-designer). + +## Phase 1 — De-noise (proposals B4, D1, D2, A3) + +Low-risk changes inside the current layout. Ship as one PR train; each item independently +revertable. + +### 1.1 Progressive validation in the compute form (B4) — `@mat3ra/ive` + +- `Compute` renders required-field errors only for touched fields (track touched state per + field) or after an explicit validate call (used by preflight later, exposed as an imperative + `validate()` ref or a `showAllErrors` prop). +- Acceptance: opening the Compute tab of a fresh job shows zero red errors; leaving a required + field empty after focusing it shows exactly one; `showAllErrors` restores today's behavior. + Size: S–M. + +### 1.2 Remove the duplicated Compute sub-tab (D1) — `@mat3ra/workflow-designer` + +- The workflow pane's internal tab strip (Overview / Important settings / Detailed view / + Compute) drops the Compute entry when the host renders its own compute surface. Add a + `hideComputeSubTab` (default false for backward compatibility) prop; job-designer passes true. +- Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. + Size: S. + +### 1.3 Humane metadata (D2) — `@mat3ra/wove` + job-designer + +> **Correction (2026-08-16, during implementation):** this was scoped to +> `@mat3ra/workflow-designer`. It is not there. The UUIDs and "idle" chips render in +> **`@mat3ra/wove`** — `components/common/CardHeader`, `components/units/UnitCard`, +> `components/workflows/WorkflowUnitCard` — which draws the subworkflow cards and flowchart +> nodes as MUI `Card`s with the id as `CardHeader` subheader and the status as a `Chip` in the +> title. Found by tracing the rendered DOM in the running job-designer demo. + +- UUIDs on subworkflow cards and flowchart nodes move behind cove's `CopyId`; status chips + render only when the job has been submitted (job-designer passes the flag; the workflow pane + already receives `adjustable={job.isInInitialStatus}`, so thread one more boolean, e.g. + `showUnitStatus`, through `workflow-designer` into wove). +- **Blocked on** the cove release (1.5) — `CopyId` and `StatusChip` must be published first. +- Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. + Size: M. + +### 1.4 First-class Submit in the header (A3) — job-designer + `@mat3ra/cove` + +- Submit leaves `getDefaultActions()` and becomes a primary header button next to Save + (rendered in both header paths: the injected `EntityHeaderComponent` and the standalone + `EntityHeader` fallback). Disabled state carries a reason string ("compute not configured"); + Terminate replaces it in running states. The dropdown keeps only select-parent and + import-style power actions. +- Mind the known cove `ButtonMultiSelect` mount-snapshot behavior (see the long note in + `Job.jsx#getSaveBtnProps`): read state at click time, never capture the entity at render. +- Terminate is destructive: it gets a confirm step (job name + elapsed time in the dialog), + unlike today's straight dropdown action. +- Acceptance: a draft job with material+workflow+compute set can be submitted in one click from + any tab; the disabled button explains what is missing; Terminate asks before killing a run; + webapp header parity is preserved. Size: M. + +### 1.5 Design-language groundwork (cove) + +- Fix the status palette (contrast + soft variants) and ship `StatusChip` and `CopyId`; + complete the missing `paletteDark` slots (`background`, `text`, `action`, `border`, `icon`, + `unitTypes`). +- Export the job-status semantic mapping (draft / queued / running / finished / error / + terminated → color + icon) from cove; items 1.3 and 1.4 consume it instead of + `text-${job.statusCls}` classes. +- Fix `ButtonMultiSelect` config resync; delete the click-time-read workaround note in + `Job.jsx` once the fixed version is consumed. +- Switch the standalone demo to cove's `DarkMaterialUITheme` (delete `demoTheme` in + `src/standalone/index.tsx`) so the completed dark palette is exercised continuously. +- Acceptance: chips and header render legibly in both themes (visual check in the cove + gallery, cove#92); the demo runs without a private theme; no consumer reads an undefined + dark-palette slot. Size: M–L. + +### 1.6 Visible error state (new; not in the original proposals) + +- `Job.jsx` wraps the designer in `ErrorBoundary` with `fallback={
}` — a render crash + currently produces a silently blank page. Replace with a visible error card (what failed, a + reload affordance, and a copyable error digest); the webapp can inject its reporting hook + through the seam. +- Acceptance: a thrown render error shows the error card in both webapp and standalone; the + fallback never renders an empty page. Size: S. diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index fc3d72e..ec25dcd 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -7,6 +7,14 @@ the interactive mockups in [`mockups/`](../../mockups/). **Branch of record for the brainstorm:** `claude/jd-ui-ux-improvements-99w9fg`. **Prior art:** SOF-7978 extracted the designer into standalone packages; SOF-7991 tracks the dependency-injection cleanup this plan must not regress. +**Parallel effort:** [SOF-8024](https://mat3ra.atlassian.net/browse/SOF-8024) is the same +exercise for the *workflow* designer ([workflow-designer#12](https://github.com/mat3ra/workflow-designer/pull/12), +six portion documents in that repo's `plan/`). The two overlap in three places, so treat its +documents as authoritative for those: its **portion 2** covers the cove design language this +plan's §"Design language" opens (cove#97 implements the semantic and dark-palette half); its +**portion 1 items 1.1/1.5** are this plan's item 1.3; and its **portion 3** covers the +`@mat3ra/ive` compute form that phase 2.3 below rebuilds. Coordinate before starting any of +those. ## Summary @@ -62,7 +70,11 @@ The platform design language lives in `@mat3ra/cove` (`dist/theme`, `dist/mui`, ### Defects to fix first (cove PRs; prerequisites for the phases below) - **`paletteDark` is skeletal**: only `primary`, `secondary`, and the four status colors — no - `background`, `text`, `action`, `border`, `icon`, or `unitTypes` slots. Anything reading + `background`, `text`, `action`, `border`, `icon`, or `unitTypes` slots. (Worth stating + precisely, because the parallel SOF-8024 audit records it as "light and dark are identical": + they are not. MUI backfills its own standard slots, so dark *looks* like stock-MUI dark, but + the custom slots resolve to `undefined` — which is exactly why wove cannot read + `unitTypes.*` in dark mode.) Anything reading `theme.palette.border.main` or `unitTypes.*` in dark mode gets `undefined`. This is the root of the light-flowchart-in-dark-shell problem (D4) and the reason the standalone demo rolls its own `createTheme` (`src/standalone/index.tsx` `demoTheme`). @@ -124,84 +136,14 @@ The platform design language lives in `@mat3ra/cove` (`dist/theme`, `dist/mui`, design-language section) rather than introducing a new one; the mockups' exact palette is illustrative. -## Phase 1 — De-noise (proposals B4, D1, D2, A3) - -Low-risk changes inside the current layout. Ship as one PR train; each item independently -revertable. - -### 1.1 Progressive validation in the compute form (B4) — `@mat3ra/ive` - -- `Compute` renders required-field errors only for touched fields (track touched state per - field) or after an explicit validate call (used by preflight later, exposed as an imperative - `validate()` ref or a `showAllErrors` prop). -- Acceptance: opening the Compute tab of a fresh job shows zero red errors; leaving a required - field empty after focusing it shows exactly one; `showAllErrors` restores today's behavior. - Size: S–M. - -### 1.2 Remove the duplicated Compute sub-tab (D1) — `@mat3ra/workflow-designer` - -- The workflow pane's internal tab strip (Overview / Important settings / Detailed view / - Compute) drops the Compute entry when the host renders its own compute surface. Add a - `hideComputeSubTab` (default false for backward compatibility) prop; job-designer passes true. -- Acceptance: Workflow tab shows exactly one place named "Compute" across the whole designer. - Size: S. - -### 1.3 Humane metadata (D2) — `@mat3ra/wove` + job-designer - -> **Correction (2026-08-16, during implementation):** this was scoped to -> `@mat3ra/workflow-designer`. It is not there. The UUIDs and "idle" chips render in -> **`@mat3ra/wove`** — `components/common/CardHeader`, `components/units/UnitCard`, -> `components/workflows/WorkflowUnitCard` — which draws the subworkflow cards and flowchart -> nodes as MUI `Card`s with the id as `CardHeader` subheader and the status as a `Chip` in the -> title. Found by tracing the rendered DOM in the running job-designer demo. - -- UUIDs on subworkflow cards and flowchart nodes move behind cove's `CopyId`; status chips - render only when the job has been submitted (job-designer passes the flag; the workflow pane - already receives `adjustable={job.isInInitialStatus}`, so thread one more boolean, e.g. - `showUnitStatus`, through `workflow-designer` into wove). -- **Blocked on** the cove release (1.5) — `CopyId` and `StatusChip` must be published first. -- Acceptance: a draft job shows no status chips and no raw UUID text; copy-id copies the id. - Size: M. - -### 1.4 First-class Submit in the header (A3) — job-designer + `@mat3ra/cove` - -- Submit leaves `getDefaultActions()` and becomes a primary header button next to Save - (rendered in both header paths: the injected `EntityHeaderComponent` and the standalone - `EntityHeader` fallback). Disabled state carries a reason string ("compute not configured"); - Terminate replaces it in running states. The dropdown keeps only select-parent and - import-style power actions. -- Mind the known cove `ButtonMultiSelect` mount-snapshot behavior (see the long note in - `Job.jsx#getSaveBtnProps`): read state at click time, never capture the entity at render. -- Terminate is destructive: it gets a confirm step (job name + elapsed time in the dialog), - unlike today's straight dropdown action. -- Acceptance: a draft job with material+workflow+compute set can be submitted in one click from - any tab; the disabled button explains what is missing; Terminate asks before killing a run; - webapp header parity is preserved. Size: M. - -### 1.5 Design-language groundwork (cove) - -- Fix the status palette (contrast + soft variants) and ship `StatusChip` and `CopyId`; - complete the missing `paletteDark` slots (`background`, `text`, `action`, `border`, `icon`, - `unitTypes`). -- Export the job-status semantic mapping (draft / queued / running / finished / error / - terminated → color + icon) from cove; items 1.3 and 1.4 consume it instead of - `text-${job.statusCls}` classes. -- Fix `ButtonMultiSelect` config resync; delete the click-time-read workaround note in - `Job.jsx` once the fixed version is consumed. -- Switch the standalone demo to cove's `DarkMaterialUITheme` (delete `demoTheme` in - `src/standalone/index.tsx`) so the completed dark palette is exercised continuously. -- Acceptance: chips and header render legibly in both themes (visual check in the cove - gallery, cove#92); the demo runs without a private theme; no consumer reads an undefined - dark-palette slot. Size: M–L. - -### 1.6 Visible error state (new; not in the original proposals) - -- `Job.jsx` wraps the designer in `ErrorBoundary` with `fallback={
}` — a render crash - currently produces a silently blank page. Replace with a visible error card (what failed, a - reload affordance, and a copyable error digest); the webapp can inject its reporting hook - through the seam. -- Acceptance: a thrown render error shows the error card in both webapp and standalone; the - fallback never renders an empty page. Size: S. +## Phase 1 — De-noise (moved) + +Built and in review. Its items, what actually shipped, and the divergences from this plan +are recorded in +[`../review/2026-08-16-Job-Designer-Phase-1-De-noise.md`](../review/2026-08-16-Job-Designer-Phase-1-De-noise.md). + +Phases are split across documents so each can move through `plan/` independently, as the +folder's own convention asks — this overview moves last. ## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) From aea238d80a8ed377cbe5186824034bb251aa1200 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 22:51:48 +0000 Subject: [PATCH 12/33] feat: materials tray and metadata panel (phase 2.5) The Materials tab was a full-bleed 3D canvas and nothing else, so the things a reader checks before spending core-hours - what formula this actually is, which lattice, how many atoms, where it came from - were only available by leaving the designer. - Tray above the viewer: a chip per material, click to switch, remove the active one, add more. Multi-material switching previously lived inside the Workflow tab, nowhere near the material being looked at. - The batch consequence is now stated rather than implied: a materials set silently turns one job into N, and the designer never said so. - Metadata panel beside the viewer, read through a defensive getMaterialSummary(): the tab already renders a fallback for hosts that pass a plain config, and model getters throw on partial data, so every field is omitted rather than allowed to take the page down. Verified in the standalone demo: Si2 / FCC / a = 3.867 A / 2 atoms / mp-149 linked to Materials Project. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 2 +- dist/components/MaterialMetadataPanel.d.ts | 12 ++ .../components/MaterialMetadataPanel.d.ts.map | 1 + dist/components/MaterialMetadataPanel.js | 41 ++++++ dist/components/MaterialTab.d.ts | 11 +- dist/components/MaterialTab.d.ts.map | 2 +- dist/components/MaterialTab.js | 14 +- dist/components/MaterialsTray.d.ts | 21 +++ dist/components/MaterialsTray.d.ts.map | 1 + dist/components/MaterialsTray.js | 30 ++++ dist/materialSummary.d.ts | 39 +++++ dist/materialSummary.d.ts.map | 1 + dist/materialSummary.js | 93 ++++++++++++ src/components/Job.jsx | 1 + src/components/MaterialMetadataPanel.tsx | 81 +++++++++++ src/components/MaterialTab.tsx | 95 ++++++++---- src/components/MaterialsTray.tsx | 93 ++++++++++++ src/materialSummary.ts | 137 ++++++++++++++++++ tests/materialSummary.tests.ts | 105 ++++++++++++++ 20 files changed, 746 insertions(+), 36 deletions(-) create mode 100644 dist/components/MaterialMetadataPanel.d.ts create mode 100644 dist/components/MaterialMetadataPanel.d.ts.map create mode 100644 dist/components/MaterialMetadataPanel.js create mode 100644 dist/components/MaterialsTray.d.ts create mode 100644 dist/components/MaterialsTray.d.ts.map create mode 100644 dist/components/MaterialsTray.js create mode 100644 dist/materialSummary.d.ts create mode 100644 dist/materialSummary.d.ts.map create mode 100644 dist/materialSummary.js create mode 100644 src/components/MaterialMetadataPanel.tsx create mode 100644 src/components/MaterialsTray.tsx create mode 100644 src/materialSummary.ts create mode 100644 tests/materialSummary.tests.ts diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index eaa435f..6566e3b 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAgIA;IAMI,wBAYC;IAVG,WAMC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAIC;IAdD;;OAEG;IACH,mBAEC;IAWD,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAEC;IAED,yCAOC;IAED,6BAEC;IAED,2DAWC;IAED,kBAAmB,YAAO,UAIxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAIlB;IAEF,eAAgB,WAAM,UAIpB;IAEF,2BAKE;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA8BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAwQC;CACJ;kBAh4BiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAgIA;IAMI,wBAYC;IAVG,WAMC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAIC;IAdD;;OAEG;IACH,mBAEC;IAWD,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAEC;IAED,yCAOC;IAED,6BAEC;IAED,2DAWC;IAED,kBAAmB,YAAO,UAIxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAIlB;IAEF,eAAgB,WAAM,UAIpB;IAEF,2BAKE;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA8BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAyQC;CACJ;kBAj4BiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index 2d357d4..f4dc8f1 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -538,7 +538,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. onSave: (omitRedirect) => this._resetStateEntityAndUpdateParents(this.state.entity, () => this.props.onSave(omitRedirect)), - }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); + }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); } } export default Job; diff --git a/dist/components/MaterialMetadataPanel.d.ts b/dist/components/MaterialMetadataPanel.d.ts new file mode 100644 index 0000000..b731fe5 --- /dev/null +++ b/dist/components/MaterialMetadataPanel.d.ts @@ -0,0 +1,12 @@ +import React from "react"; +/** + * What the structure on screen actually is, beside the viewer. + * + * The Materials tab renders a 3D canvas and nothing else, so formula, lattice, + * atom count and provenance — the things worth checking before spending + * core-hours on a run — could only be found by leaving the designer. + */ +export default function MaterialMetadataPanel({ material }: { + material: any; +}): React.JSX.Element; +//# sourceMappingURL=MaterialMetadataPanel.d.ts.map \ No newline at end of file diff --git a/dist/components/MaterialMetadataPanel.d.ts.map b/dist/components/MaterialMetadataPanel.d.ts.map new file mode 100644 index 0000000..1df2838 --- /dev/null +++ b/dist/components/MaterialMetadataPanel.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"MaterialMetadataPanel.d.ts","sourceRoot":"","sources":["../../src/components/MaterialMetadataPanel.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,OAAO,CAAC;AAuC1B;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,UAAU,qBAAqB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,GAAG,CAAA;CAAE,qBA8B5E"} \ No newline at end of file diff --git a/dist/components/MaterialMetadataPanel.js b/dist/components/MaterialMetadataPanel.js new file mode 100644 index 0000000..1876d2c --- /dev/null +++ b/dist/components/MaterialMetadataPanel.js @@ -0,0 +1,41 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { getMaterialSummary } from "../materialSummary"; +/** One fact. Rendered only when there is one — an empty row says nothing. */ +function MetadataRow({ label, children }) { + if (children === undefined || children === null || children === "") + return null; + return (_jsxs(Box, { sx: { + display: "flex", + justifyContent: "space-between", + gap: 2, + px: 2, + py: 1.25, + borderBottom: "1px solid", + borderColor: "divider", + "&:last-of-type": { borderBottom: "none" }, + }, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { flexShrink: 0 }, children: label }), _jsx(Typography, { variant: "caption", sx: { + fontFamily: (theme) => { var _a; return (_a = theme.fonts) === null || _a === void 0 ? void 0 : _a.monospace; }, + fontVariantNumeric: "tabular-nums", + textAlign: "right", + wordBreak: "break-word", + }, children: children })] })); +} +/** + * What the structure on screen actually is, beside the viewer. + * + * The Materials tab renders a 3D canvas and nothing else, so formula, lattice, + * atom count and provenance — the things worth checking before spending + * core-hours on a run — could only be found by leaving the designer. + */ +export default function MaterialMetadataPanel({ material }) { + const summary = getMaterialSummary(material); + // Nothing legible to report: better to show no panel than an empty frame. + const hasAnything = Boolean(summary.formula || summary.latticeType || summary.atomCount || summary.source); + if (!hasAnything) + return null; + return (_jsxs(Paper, { variant: "outlined", id: "job-material-metadata", sx: { overflow: "hidden" }, children: [_jsx(MetadataRow, { label: "Formula", children: summary.formula }), _jsx(MetadataRow, { label: "Lattice", children: summary.latticeType }), _jsx(MetadataRow, { label: "Parameters", children: summary.latticeParameters }), _jsx(MetadataRow, { label: "Angles", children: summary.latticeAngles }), _jsx(MetadataRow, { label: "Space group", children: summary.spaceGroup }), _jsx(MetadataRow, { label: "Atoms in cell", children: summary.atomCount }), _jsx(MetadataRow, { label: "Source", children: summary.source ? (summary.source.url ? (_jsx(Link, { href: summary.source.url, target: "_blank", rel: "noopener noreferrer", children: summary.source.id })) : (summary.source.id)) : undefined })] })); +} diff --git a/dist/components/MaterialTab.d.ts b/dist/components/MaterialTab.d.ts index 3d8a17c..b6fba52 100644 --- a/dist/components/MaterialTab.d.ts +++ b/dist/components/MaterialTab.d.ts @@ -25,9 +25,16 @@ interface MaterialTabProps { publicAccount: any; profile: any; addRemoveAllowed: boolean | (() => void); - onUpdateIndex: () => void; + /** Switches the material in the viewer; dispatches `switchMaterialByIndex`. */ + onUpdateIndex: (index: number) => void; onMaterialRemove: () => void; openAddMaterialsDialog: () => void; + /** + * Every material attached to the job, for the tray above the viewer. Optional + * so hosts that only pass the active material keep working — the tray is then + * simply not rendered. + */ + materials?: any[]; /** * Optional injectable material viewer component (e.g. ThreeDEditor from wave.js or a mave component). * When provided it receives the full {@link MaterialViewerComponentProps} so that it can render @@ -36,6 +43,6 @@ interface MaterialTabProps { */ MaterialViewerComponent?: React.ComponentType; } -declare function MaterialTab({ className, id, role, material, index, length, publicAccount, profile, addRemoveAllowed, onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, MaterialViewerComponent, }: MaterialTabProps): React.JSX.Element; +declare function MaterialTab({ className, id, role, material, index, length, publicAccount, profile, addRemoveAllowed, onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, materials, MaterialViewerComponent, }: MaterialTabProps): React.JSX.Element; export default MaterialTab; //# sourceMappingURL=MaterialTab.d.ts.map \ No newline at end of file diff --git a/dist/components/MaterialTab.d.ts.map b/dist/components/MaterialTab.d.ts.map index 1bde0cb..d0ae0bc 100644 --- a/dist/components/MaterialTab.d.ts.map +++ b/dist/components/MaterialTab.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"MaterialTab.d.ts","sourceRoot":"","sources":["../../src/components/MaterialTab.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IACzC,QAAQ,EAAE,GAAG,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAgB;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,sBAAsB,EAAE,MAAM,IAAI,CAAC;IACnC;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,4BAA4B,CAAC,CAAC;CAC/E;AAED,iBAAS,WAAW,CAAC,EACjB,SAAS,EACT,EAAE,EACF,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,aAAa,EACb,OAAO,EACP,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,uBAAuB,GAC1B,EAAE,gBAAgB,qBAiClB;AAED,eAAe,WAAW,CAAC"} \ No newline at end of file +{"version":3,"file":"MaterialTab.d.ts","sourceRoot":"","sources":["../../src/components/MaterialTab.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAgB1B;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IACzC,QAAQ,EAAE,GAAG,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAgB;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,+EAA+E;IAC/E,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,sBAAsB,EAAE,MAAM,IAAI,CAAC;IACnC;;;;OAIG;IACH,SAAS,CAAC,EAAE,GAAG,EAAE,CAAC;IAClB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,4BAA4B,CAAC,CAAC;CAC/E;AAED,iBAAS,WAAW,CAAC,EACjB,SAAS,EACT,EAAE,EACF,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,aAAa,EACb,OAAO,EACP,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,SAAS,EACT,uBAAuB,GAC1B,EAAE,gBAAgB,qBA0DlB;AAED,eAAe,WAAW,CAAC"} \ No newline at end of file diff --git a/dist/components/MaterialTab.js b/dist/components/MaterialTab.js index 2576c75..79083f1 100644 --- a/dist/components/MaterialTab.js +++ b/dist/components/MaterialTab.js @@ -1,5 +1,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import Box from "@mui/material/Box"; import setClass from "classnames"; +import MaterialMetadataPanel from "./MaterialMetadataPanel"; +import MaterialsTray from "./MaterialsTray"; // In standalone mode, @mat3ra/made exports the data class (not a React component). // Render a simple read-only display of the material name as a fallback. function MaterialNameFallback({ material }) { @@ -7,7 +10,14 @@ function MaterialNameFallback({ material }) { const name = (_b = (_a = material === null || material === void 0 ? void 0 : material.name) !== null && _a !== void 0 ? _a : material === null || material === void 0 ? void 0 : material.formula) !== null && _b !== void 0 ? _b : "Material"; return (_jsxs("div", { style: { padding: "16px" }, children: [_jsx("strong", { children: "Material:" }), " ", name] })); } -function MaterialTab({ className, id, role, material, index, length, publicAccount, profile, addRemoveAllowed, onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, MaterialViewerComponent, }) { - return (_jsx("div", { className: setClass(className), id: id, role: role, style: { height: "100%" }, children: MaterialViewerComponent ? (_jsx(MaterialViewerComponent, { material: material, index: index, length: length, publicAccount: publicAccount, profile: profile, addRemoveAllowed: addRemoveAllowed, onUpdateIndex: onUpdateIndex, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog })) : (_jsx(MaterialNameFallback, { material: material, publicAccount: publicAccount, profile: profile, index: index, length: length, editable: false, addRemoveAllowed: addRemoveAllowed, onUpdateIndex: onUpdateIndex, showHeader: true, showMetadata: true, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog })) })); +function MaterialTab({ className, id, role, material, index, length, publicAccount, profile, addRemoveAllowed, onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, materials, MaterialViewerComponent, }) { + const trayMaterials = (materials === null || materials === void 0 ? void 0 : materials.length) ? materials : material ? [material] : []; + return (_jsxs("div", { className: setClass(className), id: id, role: role, style: { height: "100%" }, children: [_jsx(MaterialsTray, { materials: trayMaterials, activeIndex: index, onSelect: onUpdateIndex, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog, editable: Boolean(addRemoveAllowed) }), _jsxs(Box, { sx: { + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(240px, 300px)" }, + gap: 2, + p: 2, + alignItems: "start", + }, children: [_jsx(Box, { sx: { minWidth: 0 }, children: MaterialViewerComponent ? (_jsx(MaterialViewerComponent, { material: material, index: index, length: length, publicAccount: publicAccount, profile: profile, addRemoveAllowed: addRemoveAllowed, onUpdateIndex: onUpdateIndex, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog })) : (_jsx(MaterialNameFallback, { material: material, publicAccount: publicAccount, profile: profile, index: index, length: length, editable: false, addRemoveAllowed: addRemoveAllowed, onUpdateIndex: onUpdateIndex, showHeader: true, showMetadata: true, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog })) }), _jsx(MaterialMetadataPanel, { material: material })] })] })); } export default MaterialTab; diff --git a/dist/components/MaterialsTray.d.ts b/dist/components/MaterialsTray.d.ts new file mode 100644 index 0000000..f3d449e --- /dev/null +++ b/dist/components/MaterialsTray.d.ts @@ -0,0 +1,21 @@ +import React from "react"; +export interface MaterialsTrayProps { + materials: any[]; + /** Index of the material currently in the viewer. */ + activeIndex: number; + onSelect: (index: number) => void; + onRemove?: () => void; + onAdd?: () => void; + /** False once the job is saved — its material set is fixed by then. */ + editable?: boolean; +} +/** + * The job's materials, above the viewer. + * + * Two things were previously unavailable here: switching between materials in a + * multi-material job lived inside the *Workflow* tab, nowhere near the material + * being looked at; and a materials set silently turns one job into N, which the + * designer never said out loud. + */ +export default function MaterialsTray({ materials, activeIndex, onSelect, onRemove, onAdd, editable, }: MaterialsTrayProps): React.JSX.Element; +//# sourceMappingURL=MaterialsTray.d.ts.map \ No newline at end of file diff --git a/dist/components/MaterialsTray.d.ts.map b/dist/components/MaterialsTray.d.ts.map new file mode 100644 index 0000000..f980a14 --- /dev/null +++ b/dist/components/MaterialsTray.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"MaterialsTray.d.ts","sourceRoot":"","sources":["../../src/components/MaterialsTray.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,MAAM,WAAW,kBAAkB;IAC/B,SAAS,EAAE,GAAG,EAAE,CAAC;IACjB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,OAAO,UAAU,aAAa,CAAC,EAClC,SAAS,EACT,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,QAAe,GAClB,EAAE,kBAAkB,qBAwDpB"} \ No newline at end of file diff --git a/dist/components/MaterialsTray.js b/dist/components/MaterialsTray.js new file mode 100644 index 0000000..c3497e3 --- /dev/null +++ b/dist/components/MaterialsTray.js @@ -0,0 +1,30 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { getBatchDescription, getMaterialSummary } from "../materialSummary"; +/** + * The job's materials, above the viewer. + * + * Two things were previously unavailable here: switching between materials in a + * multi-material job lived inside the *Workflow* tab, nowhere near the material + * being looked at; and a materials set silently turns one job into N, which the + * designer never said out loud. + */ +export default function MaterialsTray({ materials, activeIndex, onSelect, onRemove, onAdd, editable = true, }) { + if (!(materials === null || materials === void 0 ? void 0 : materials.length)) + return null; + const isBatch = materials.length > 1; + return (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", useFlexGap: true, sx: { px: 2, pt: 2 }, id: "job-materials-tray", children: [materials.map((material, index) => { + var _a; + const { formula, name } = getMaterialSummary(material); + const isActive = index === activeIndex; + return (_jsx(Chip, { size: "small", variant: isActive ? "filled" : "outlined", color: isActive ? "primary" : "default", label: (_a = formula !== null && formula !== void 0 ? formula : name) !== null && _a !== void 0 ? _a : `Material ${index + 1}`, title: name, onClick: () => onSelect(index), + // Only the material on screen can be removed: the callback the + // host gives us acts on the active one. + onDelete: editable && isActive && onRemove ? onRemove : undefined }, name !== null && name !== void 0 ? name : index)); + }), editable && onAdd ? (_jsx(Button, { size: "small", startIcon: _jsx(IconByName, { name: "actions.add", fontSize: "small" }), onClick: onAdd, children: "Add material" })) : null, _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Typography, { variant: "caption", color: isBatch ? "text.primary" : "text.secondary", id: "job-materials-batch-note", children: getBatchDescription(materials.length) })] })); +} diff --git a/dist/materialSummary.d.ts b/dist/materialSummary.d.ts new file mode 100644 index 0000000..23aab71 --- /dev/null +++ b/dist/materialSummary.d.ts @@ -0,0 +1,39 @@ +/** + * The facts about a material worth putting next to the 3D viewer. + * + * The Materials tab is a full-bleed canvas today: it shows the structure and + * nothing else, so the things a reader checks before running a job — what + * formula this actually is, which lattice, how many atoms, where it came from — + * are only available by leaving the designer. + * + * Deliberately defensive. `MaterialTab` already renders a fallback for hosts + * that pass something other than a made `Material` (the standalone demo used + * to), and model getters can throw on partial configs. A metadata panel that + * takes the page down with it would be worse than no panel, so every field is + * read through {@link readSafely} and simply omitted when unavailable. + */ +export interface MaterialSource { + id: string; + name?: string; + url?: string; +} +export interface MaterialSummary { + name?: string; + /** Unit-cell formula where known ("Si2"), otherwise the reduced one ("Si"). */ + formula?: string; + latticeType?: string; + /** Pre-formatted lattice constants, e.g. "a = 3.867 Å" or "a 3.87 · b 3.87 · c 5.02 Å". */ + latticeParameters?: string; + /** Pre-formatted angles, omitted when the cell is a right prism. */ + latticeAngles?: string; + atomCount?: number; + spaceGroup?: string; + source?: MaterialSource; +} +export declare function getMaterialSummary(material: any): MaterialSummary; +/** + * The consequence of a multi-material selection, stated rather than implied. + * A materials set silently turns one job into N, which the designer never says. + */ +export declare function getBatchDescription(materialCount: number): string; +//# sourceMappingURL=materialSummary.d.ts.map \ No newline at end of file diff --git a/dist/materialSummary.d.ts.map b/dist/materialSummary.d.ts.map new file mode 100644 index 0000000..3b17b75 --- /dev/null +++ b/dist/materialSummary.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"materialSummary.d.ts","sourceRoot":"","sources":["../src/materialSummary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,cAAc;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,cAAc,CAAC;CAC3B;AAgFD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,GAAG,GAAG,eAAe,CAajE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAIjE"} \ No newline at end of file diff --git a/dist/materialSummary.js b/dist/materialSummary.js new file mode 100644 index 0000000..b83b124 --- /dev/null +++ b/dist/materialSummary.js @@ -0,0 +1,93 @@ +/** + * The facts about a material worth putting next to the 3D viewer. + * + * The Materials tab is a full-bleed canvas today: it shows the structure and + * nothing else, so the things a reader checks before running a job — what + * formula this actually is, which lattice, how many atoms, where it came from — + * are only available by leaving the designer. + * + * Deliberately defensive. `MaterialTab` already renders a fallback for hosts + * that pass something other than a made `Material` (the standalone demo used + * to), and model getters can throw on partial configs. A metadata panel that + * takes the page down with it would be worse than no panel, so every field is + * read through {@link readSafely} and simply omitted when unavailable. + */ +function readSafely(read) { + try { + return read(); + } + catch (_a) { + return undefined; + } +} +const ANGSTROM = "Å"; +const MIDDLE_DOT = "·"; +/** Lattice constants agreeing to this many decimals are treated as one value. */ +const LATTICE_EQUALITY_TOLERANCE = 1e-4; +function roundToDecimals(value, decimals = 3) { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} +function formatLatticeParameters(lattice) { + const { a, b, c } = lattice !== null && lattice !== void 0 ? lattice : {}; + if (![a, b, c].every((value) => typeof value === "number" && Number.isFinite(value))) { + return undefined; + } + const isCubicCell = Math.abs(a - b) < LATTICE_EQUALITY_TOLERANCE && + Math.abs(a - c) < LATTICE_EQUALITY_TOLERANCE; + // Repeating one number three times says less than showing it once. + if (isCubicCell) + return `a = ${roundToDecimals(a)} ${ANGSTROM}`; + return `a ${roundToDecimals(a)} ${MIDDLE_DOT} b ${roundToDecimals(b)} ${MIDDLE_DOT} c ${roundToDecimals(c)} ${ANGSTROM}`; +} +function formatLatticeAngles(lattice) { + const { alpha, beta, gamma } = lattice !== null && lattice !== void 0 ? lattice : {}; + if (![alpha, beta, gamma].every((v) => typeof v === "number" && Number.isFinite(v))) { + return undefined; + } + // 90/90/90 is the unremarkable case; saying so spends a row to say nothing. + const isRightPrism = [alpha, beta, gamma].every((angle) => Math.abs(angle - 90) < LATTICE_EQUALITY_TOLERANCE); + if (isRightPrism) + return undefined; + return `${roundToDecimals(alpha, 1)}° ${MIDDLE_DOT} ${roundToDecimals(beta, 1)}° ${MIDDLE_DOT} ${roundToDecimals(gamma, 1)}°`; +} +function getAtomCount(material) { + const count = readSafely(() => { var _a, _b, _c, _d, _e; return (_c = (_b = (_a = material === null || material === void 0 ? void 0 : material.Basis) === null || _a === void 0 ? void 0 : _a.elements) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : (_e = (_d = material === null || material === void 0 ? void 0 : material.basis) === null || _d === void 0 ? void 0 : _d.elements) === null || _e === void 0 ? void 0 : _e.length; }); + return typeof count === "number" ? count : undefined; +} +function getSpaceGroup(material) { + var _a, _b; + const derivedProperties = (_a = readSafely(() => material === null || material === void 0 ? void 0 : material.derivedProperties)) !== null && _a !== void 0 ? _a : []; + const symmetry = Array.isArray(derivedProperties) + ? derivedProperties.find((property) => (property === null || property === void 0 ? void 0 : property.name) === "symmetry") + : undefined; + return (_b = symmetry === null || symmetry === void 0 ? void 0 : symmetry.spaceGroupSymbol) !== null && _b !== void 0 ? _b : undefined; +} +function getSource(material) { + const external = readSafely(() => { var _a, _b; return (_a = material === null || material === void 0 ? void 0 : material.external) !== null && _a !== void 0 ? _a : (_b = material === null || material === void 0 ? void 0 : material._json) === null || _b === void 0 ? void 0 : _b.external; }); + if (!(external === null || external === void 0 ? void 0 : external.id)) + return undefined; + return { id: String(external.id), name: external.source, url: external.url }; +} +export function getMaterialSummary(material) { + const lattice = readSafely(() => material === null || material === void 0 ? void 0 : material.lattice); + return { + name: readSafely(() => material === null || material === void 0 ? void 0 : material.name), + formula: readSafely(() => material === null || material === void 0 ? void 0 : material.unitCellFormula) || readSafely(() => material === null || material === void 0 ? void 0 : material.formula), + latticeType: readSafely(() => lattice === null || lattice === void 0 ? void 0 : lattice.type), + latticeParameters: formatLatticeParameters(lattice), + latticeAngles: formatLatticeAngles(lattice), + atomCount: getAtomCount(material), + spaceGroup: getSpaceGroup(material), + source: getSource(material), + }; +} +/** + * The consequence of a multi-material selection, stated rather than implied. + * A materials set silently turns one job into N, which the designer never says. + */ +export function getBatchDescription(materialCount) { + if (materialCount <= 1) + return "The workflow runs once."; + return `${materialCount} materials — the workflow runs ${materialCount} times, once per material.`; +} diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 8e4884b..d7a9ac5 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -811,6 +811,7 @@ class Job extends mix(React.Component).with( index={index} length={length} onUpdateIndex={onUpdateIndex} + materials={materials} onMaterialRemove={onMaterialRemove} addRemoveAllowed={!job.id} openAddMaterialsDialog={this.openAddMaterialsDialog} diff --git a/src/components/MaterialMetadataPanel.tsx b/src/components/MaterialMetadataPanel.tsx new file mode 100644 index 0000000..a7a1fb2 --- /dev/null +++ b/src/components/MaterialMetadataPanel.tsx @@ -0,0 +1,81 @@ +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { getMaterialSummary } from "../materialSummary"; + +/** One fact. Rendered only when there is one — an empty row says nothing. */ +function MetadataRow({ label, children }: { label: string; children?: React.ReactNode }) { + if (children === undefined || children === null || children === "") return null; + + return ( + + + {label} + + (theme as any).fonts?.monospace, + fontVariantNumeric: "tabular-nums", + textAlign: "right", + wordBreak: "break-word", + }} + > + {children} + + + ); +} + +/** + * What the structure on screen actually is, beside the viewer. + * + * The Materials tab renders a 3D canvas and nothing else, so formula, lattice, + * atom count and provenance — the things worth checking before spending + * core-hours on a run — could only be found by leaving the designer. + */ +export default function MaterialMetadataPanel({ material }: { material: any }) { + const summary = getMaterialSummary(material); + + // Nothing legible to report: better to show no panel than an empty frame. + const hasAnything = Boolean( + summary.formula || summary.latticeType || summary.atomCount || summary.source, + ); + if (!hasAnything) return null; + + return ( + + {summary.formula} + {summary.latticeType} + {summary.latticeParameters} + {summary.latticeAngles} + {summary.spaceGroup} + {summary.atomCount} + + {summary.source ? ( + summary.source.url ? ( + + {summary.source.id} + + ) : ( + summary.source.id + ) + ) : undefined} + + + ); +} diff --git a/src/components/MaterialTab.tsx b/src/components/MaterialTab.tsx index 3210a44..e629745 100644 --- a/src/components/MaterialTab.tsx +++ b/src/components/MaterialTab.tsx @@ -1,6 +1,10 @@ +import Box from "@mui/material/Box"; import setClass from "classnames"; import React from "react"; +import MaterialMetadataPanel from "./MaterialMetadataPanel"; +import MaterialsTray from "./MaterialsTray"; + // In standalone mode, @mat3ra/made exports the data class (not a React component). // Render a simple read-only display of the material name as a fallback. function MaterialNameFallback({ material }: { material: any; [key: string]: any }) { @@ -39,9 +43,16 @@ interface MaterialTabProps { publicAccount: any; profile: any; addRemoveAllowed: boolean | (() => void); - onUpdateIndex: () => void; + /** Switches the material in the viewer; dispatches `switchMaterialByIndex`. */ + onUpdateIndex: (index: number) => void; onMaterialRemove: () => void; openAddMaterialsDialog: () => void; + /** + * Every material attached to the job, for the tray above the viewer. Optional + * so hosts that only pass the active material keep working — the tray is then + * simply not rendered. + */ + materials?: any[]; /** * Optional injectable material viewer component (e.g. ThreeDEditor from wave.js or a mave component). * When provided it receives the full {@link MaterialViewerComponentProps} so that it can render @@ -64,38 +75,64 @@ function MaterialTab({ onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, + materials, MaterialViewerComponent, }: MaterialTabProps) { + const trayMaterials = materials?.length ? materials : material ? [material] : []; + return (
- {MaterialViewerComponent ? ( - - ) : ( - - )} + + {/* Viewer and metadata side by side: the structure is the subject, the + facts about it are what a reader checks before spending core-hours. */} + + + {MaterialViewerComponent ? ( + void} + onRemove={onMaterialRemove} + onAdd={openAddMaterialsDialog} + /> + ) : ( + + )} + + +
); } diff --git a/src/components/MaterialsTray.tsx b/src/components/MaterialsTray.tsx new file mode 100644 index 0000000..af350fe --- /dev/null +++ b/src/components/MaterialsTray.tsx @@ -0,0 +1,93 @@ +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import { getBatchDescription, getMaterialSummary } from "../materialSummary"; + +export interface MaterialsTrayProps { + materials: any[]; + /** Index of the material currently in the viewer. */ + activeIndex: number; + onSelect: (index: number) => void; + onRemove?: () => void; + onAdd?: () => void; + /** False once the job is saved — its material set is fixed by then. */ + editable?: boolean; +} + +/** + * The job's materials, above the viewer. + * + * Two things were previously unavailable here: switching between materials in a + * multi-material job lived inside the *Workflow* tab, nowhere near the material + * being looked at; and a materials set silently turns one job into N, which the + * designer never said out loud. + */ +export default function MaterialsTray({ + materials, + activeIndex, + onSelect, + onRemove, + onAdd, + editable = true, +}: MaterialsTrayProps) { + if (!materials?.length) return null; + + const isBatch = materials.length > 1; + + return ( + + {materials.map((material, index) => { + const { formula, name } = getMaterialSummary(material); + const isActive = index === activeIndex; + + return ( + onSelect(index)} + // Only the material on screen can be removed: the callback the + // host gives us acts on the active one. + onDelete={editable && isActive && onRemove ? onRemove : undefined} + /> + ); + })} + + {editable && onAdd ? ( + + ) : null} + + + + + {getBatchDescription(materials.length)} + + + ); +} diff --git a/src/materialSummary.ts b/src/materialSummary.ts new file mode 100644 index 0000000..3134c99 --- /dev/null +++ b/src/materialSummary.ts @@ -0,0 +1,137 @@ +/** + * The facts about a material worth putting next to the 3D viewer. + * + * The Materials tab is a full-bleed canvas today: it shows the structure and + * nothing else, so the things a reader checks before running a job — what + * formula this actually is, which lattice, how many atoms, where it came from — + * are only available by leaving the designer. + * + * Deliberately defensive. `MaterialTab` already renders a fallback for hosts + * that pass something other than a made `Material` (the standalone demo used + * to), and model getters can throw on partial configs. A metadata panel that + * takes the page down with it would be worse than no panel, so every field is + * read through {@link readSafely} and simply omitted when unavailable. + */ + +export interface MaterialSource { + id: string; + name?: string; + url?: string; +} + +export interface MaterialSummary { + name?: string; + /** Unit-cell formula where known ("Si2"), otherwise the reduced one ("Si"). */ + formula?: string; + latticeType?: string; + /** Pre-formatted lattice constants, e.g. "a = 3.867 Å" or "a 3.87 · b 3.87 · c 5.02 Å". */ + latticeParameters?: string; + /** Pre-formatted angles, omitted when the cell is a right prism. */ + latticeAngles?: string; + atomCount?: number; + spaceGroup?: string; + source?: MaterialSource; +} + +function readSafely(read: () => T | undefined): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} + +const ANGSTROM = "Å"; +const MIDDLE_DOT = "·"; +/** Lattice constants agreeing to this many decimals are treated as one value. */ +const LATTICE_EQUALITY_TOLERANCE = 1e-4; + +function roundToDecimals(value: number, decimals = 3): number { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +function formatLatticeParameters(lattice: any): string | undefined { + const { a, b, c } = lattice ?? {}; + if (![a, b, c].every((value) => typeof value === "number" && Number.isFinite(value))) { + return undefined; + } + + const isCubicCell = + Math.abs(a - b) < LATTICE_EQUALITY_TOLERANCE && + Math.abs(a - c) < LATTICE_EQUALITY_TOLERANCE; + + // Repeating one number three times says less than showing it once. + if (isCubicCell) return `a = ${roundToDecimals(a)} ${ANGSTROM}`; + + return `a ${roundToDecimals(a)} ${MIDDLE_DOT} b ${roundToDecimals( + b, + )} ${MIDDLE_DOT} c ${roundToDecimals(c)} ${ANGSTROM}`; +} + +function formatLatticeAngles(lattice: any): string | undefined { + const { alpha, beta, gamma } = lattice ?? {}; + if (![alpha, beta, gamma].every((v) => typeof v === "number" && Number.isFinite(v))) { + return undefined; + } + + // 90/90/90 is the unremarkable case; saying so spends a row to say nothing. + const isRightPrism = [alpha, beta, gamma].every( + (angle) => Math.abs(angle - 90) < LATTICE_EQUALITY_TOLERANCE, + ); + if (isRightPrism) return undefined; + + return `${roundToDecimals(alpha, 1)}° ${MIDDLE_DOT} ${roundToDecimals( + beta, + 1, + )}° ${MIDDLE_DOT} ${roundToDecimals(gamma, 1)}°`; +} + +function getAtomCount(material: any): number | undefined { + const count = readSafely( + () => material?.Basis?.elements?.length ?? material?.basis?.elements?.length, + ); + + return typeof count === "number" ? count : undefined; +} + +function getSpaceGroup(material: any): string | undefined { + const derivedProperties = readSafely(() => material?.derivedProperties) ?? []; + const symmetry = Array.isArray(derivedProperties) + ? derivedProperties.find((property: any) => property?.name === "symmetry") + : undefined; + + return symmetry?.spaceGroupSymbol ?? undefined; +} + +function getSource(material: any): MaterialSource | undefined { + const external = readSafely(() => material?.external ?? material?._json?.external); + if (!external?.id) return undefined; + + return { id: String(external.id), name: external.source, url: external.url }; +} + +export function getMaterialSummary(material: any): MaterialSummary { + const lattice = readSafely(() => material?.lattice); + + return { + name: readSafely(() => material?.name), + formula: readSafely(() => material?.unitCellFormula) || readSafely(() => material?.formula), + latticeType: readSafely(() => lattice?.type), + latticeParameters: formatLatticeParameters(lattice), + latticeAngles: formatLatticeAngles(lattice), + atomCount: getAtomCount(material), + spaceGroup: getSpaceGroup(material), + source: getSource(material), + }; +} + +/** + * The consequence of a multi-material selection, stated rather than implied. + * A materials set silently turns one job into N, which the designer never says. + */ +export function getBatchDescription(materialCount: number): string { + if (materialCount <= 1) return "The workflow runs once."; + + return `${materialCount} materials — the workflow runs ${materialCount} times, once per material.`; +} diff --git a/tests/materialSummary.tests.ts b/tests/materialSummary.tests.ts new file mode 100644 index 0000000..6c0ecd3 --- /dev/null +++ b/tests/materialSummary.tests.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getBatchDescription, getMaterialSummary } from "../src/materialSummary"; + +const siliconLike = { + name: "Si, Silicon, FCC (Fd-3m) 3D (Bulk), mp-149", + formula: "Si", + unitCellFormula: "Si2", + lattice: { type: "FCC", a: 3.866976, b: 3.866975, c: 3.866975, alpha: 60, beta: 60, gamma: 60 }, + Basis: { elements: [{ id: 0 }, { id: 1 }] }, + external: { + id: "mp-149", + source: "MaterialsProject", + url: "https://next-gen.materialsproject.org/materials/mp-149", + }, +}; + +describe("getMaterialSummary", () => { + it("prefers the unit-cell formula over the reduced one", () => { + // "Si" tells you less than "Si2" when deciding what will run. + assert.equal(getMaterialSummary(siliconLike).formula, "Si2"); + assert.equal(getMaterialSummary({ formula: "GaN" }).formula, "GaN"); + }); + + it("collapses equal lattice constants to a single value", () => { + assert.equal(getMaterialSummary(siliconLike).latticeParameters, "a = 3.867 Å"); + }); + + it("lists constants separately when the cell is not cubic", () => { + const summary = getMaterialSummary({ + lattice: { type: "HEX", a: 3.19, b: 3.19, c: 5.189, alpha: 90, beta: 90, gamma: 120 }, + }); + assert.equal(summary.latticeParameters, "a 3.19 · b 3.19 · c 5.189 Å"); + }); + + it("shows angles only when they are not all right angles", () => { + assert.equal(getMaterialSummary(siliconLike).latticeAngles, "60° · 60° · 60°"); + const cubic = getMaterialSummary({ + lattice: { type: "CUB", a: 5, b: 5, c: 5, alpha: 90, beta: 90, gamma: 90 }, + }); + assert.equal(cubic.latticeAngles, undefined); + }); + + it("reads atom count from either basis spelling", () => { + assert.equal(getMaterialSummary(siliconLike).atomCount, 2); + assert.equal(getMaterialSummary({ basis: { elements: [1, 2, 3] } }).atomCount, 3); + }); + + it("carries the source through with its link", () => { + const { source } = getMaterialSummary(siliconLike); + assert.equal(source?.id, "mp-149"); + assert.equal(source?.name, "MaterialsProject"); + assert.ok(source?.url?.includes("mp-149")); + }); + + it("finds a space group only when the model carries one", () => { + assert.equal(getMaterialSummary(siliconLike).spaceGroup, undefined); + const withSymmetry = getMaterialSummary({ + derivedProperties: [{ name: "symmetry", spaceGroupSymbol: "Fd-3m" }], + }); + assert.equal(withSymmetry.spaceGroup, "Fd-3m"); + }); + + it("omits fields rather than throwing on partial or hostile input", () => { + // The tab renders a fallback for hosts that pass a plain config, and model + // getters can throw on incomplete data — a metadata panel must not take the + // page down with it. + assert.deepEqual(getMaterialSummary(undefined), { + name: undefined, + formula: undefined, + latticeType: undefined, + latticeParameters: undefined, + latticeAngles: undefined, + atomCount: undefined, + spaceGroup: undefined, + source: undefined, + }); + + const throwing = { + get lattice() { + throw new Error("no lattice on this config"); + }, + get name() { + throw new Error("nope"); + }, + }; + assert.doesNotThrow(() => getMaterialSummary(throwing)); + assert.equal(getMaterialSummary(throwing).latticeParameters, undefined); + }); +}); + +describe("getBatchDescription", () => { + it("states the consequence a materials set otherwise leaves implied", () => { + assert.equal( + getBatchDescription(3), + "3 materials — the workflow runs 3 times, once per material.", + ); + }); + + it("does not imply a batch for a single material", () => { + assert.equal(getBatchDescription(1), "The workflow runs once."); + assert.equal(getBatchDescription(0), "The workflow runs once."); + }); +}); From c030e9977087977c7d2c49488a0fc97d3b03588d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 22:53:37 +0000 Subject: [PATCH 13/33] refactor: extract nested ternaries flagged by lint Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/MaterialMetadataPanel.d.ts | 7 ------ .../components/MaterialMetadataPanel.d.ts.map | 2 +- dist/components/MaterialMetadataPanel.js | 10 ++++++++- dist/components/MaterialTab.d.ts.map | 2 +- dist/components/MaterialTab.js | 7 +++++- src/components/MaterialMetadataPanel.tsx | 22 +++++++++++-------- src/components/MaterialTab.tsx | 5 ++++- 7 files changed, 34 insertions(+), 21 deletions(-) diff --git a/dist/components/MaterialMetadataPanel.d.ts b/dist/components/MaterialMetadataPanel.d.ts index b731fe5..2301df2 100644 --- a/dist/components/MaterialMetadataPanel.d.ts +++ b/dist/components/MaterialMetadataPanel.d.ts @@ -1,11 +1,4 @@ import React from "react"; -/** - * What the structure on screen actually is, beside the viewer. - * - * The Materials tab renders a 3D canvas and nothing else, so formula, lattice, - * atom count and provenance — the things worth checking before spending - * core-hours on a run — could only be found by leaving the designer. - */ export default function MaterialMetadataPanel({ material }: { material: any; }): React.JSX.Element; diff --git a/dist/components/MaterialMetadataPanel.d.ts.map b/dist/components/MaterialMetadataPanel.d.ts.map index 1df2838..bd103b3 100644 --- a/dist/components/MaterialMetadataPanel.d.ts.map +++ b/dist/components/MaterialMetadataPanel.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"MaterialMetadataPanel.d.ts","sourceRoot":"","sources":["../../src/components/MaterialMetadataPanel.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,OAAO,CAAC;AAuC1B;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,UAAU,qBAAqB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,GAAG,CAAA;CAAE,qBA8B5E"} \ No newline at end of file +{"version":3,"file":"MaterialMetadataPanel.d.ts","sourceRoot":"","sources":["../../src/components/MaterialMetadataPanel.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,OAAO,CAAC;AA0D1B,MAAM,CAAC,OAAO,UAAU,qBAAqB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,GAAG,CAAA;CAAE,qBAsB5E"} \ No newline at end of file diff --git a/dist/components/MaterialMetadataPanel.js b/dist/components/MaterialMetadataPanel.js index 1876d2c..8c31a1d 100644 --- a/dist/components/MaterialMetadataPanel.js +++ b/dist/components/MaterialMetadataPanel.js @@ -31,11 +31,19 @@ function MetadataRow({ label, children }) { * atom count and provenance — the things worth checking before spending * core-hours on a run — could only be found by leaving the designer. */ +/** The source id, linked out when the model knows where it came from. */ +function SourceValue({ source }) { + if (!source) + return null; + if (!source.url) + return _jsx("span", { children: source.id }); + return (_jsx(Link, { href: source.url, target: "_blank", rel: "noopener noreferrer", children: source.id })); +} export default function MaterialMetadataPanel({ material }) { const summary = getMaterialSummary(material); // Nothing legible to report: better to show no panel than an empty frame. const hasAnything = Boolean(summary.formula || summary.latticeType || summary.atomCount || summary.source); if (!hasAnything) return null; - return (_jsxs(Paper, { variant: "outlined", id: "job-material-metadata", sx: { overflow: "hidden" }, children: [_jsx(MetadataRow, { label: "Formula", children: summary.formula }), _jsx(MetadataRow, { label: "Lattice", children: summary.latticeType }), _jsx(MetadataRow, { label: "Parameters", children: summary.latticeParameters }), _jsx(MetadataRow, { label: "Angles", children: summary.latticeAngles }), _jsx(MetadataRow, { label: "Space group", children: summary.spaceGroup }), _jsx(MetadataRow, { label: "Atoms in cell", children: summary.atomCount }), _jsx(MetadataRow, { label: "Source", children: summary.source ? (summary.source.url ? (_jsx(Link, { href: summary.source.url, target: "_blank", rel: "noopener noreferrer", children: summary.source.id })) : (summary.source.id)) : undefined })] })); + return (_jsxs(Paper, { variant: "outlined", id: "job-material-metadata", sx: { overflow: "hidden" }, children: [_jsx(MetadataRow, { label: "Formula", children: summary.formula }), _jsx(MetadataRow, { label: "Lattice", children: summary.latticeType }), _jsx(MetadataRow, { label: "Parameters", children: summary.latticeParameters }), _jsx(MetadataRow, { label: "Angles", children: summary.latticeAngles }), _jsx(MetadataRow, { label: "Space group", children: summary.spaceGroup }), _jsx(MetadataRow, { label: "Atoms in cell", children: summary.atomCount }), _jsx(MetadataRow, { label: "Source", children: summary.source ? _jsx(SourceValue, { source: summary.source }) : undefined })] })); } diff --git a/dist/components/MaterialTab.d.ts.map b/dist/components/MaterialTab.d.ts.map index d0ae0bc..eeac479 100644 --- a/dist/components/MaterialTab.d.ts.map +++ b/dist/components/MaterialTab.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"MaterialTab.d.ts","sourceRoot":"","sources":["../../src/components/MaterialTab.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAgB1B;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IACzC,QAAQ,EAAE,GAAG,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAgB;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,+EAA+E;IAC/E,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,sBAAsB,EAAE,MAAM,IAAI,CAAC;IACnC;;;;OAIG;IACH,SAAS,CAAC,EAAE,GAAG,EAAE,CAAC;IAClB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,4BAA4B,CAAC,CAAC;CAC/E;AAED,iBAAS,WAAW,CAAC,EACjB,SAAS,EACT,EAAE,EACF,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,aAAa,EACb,OAAO,EACP,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,SAAS,EACT,uBAAuB,GAC1B,EAAE,gBAAgB,qBA0DlB;AAED,eAAe,WAAW,CAAC"} \ No newline at end of file +{"version":3,"file":"MaterialTab.d.ts","sourceRoot":"","sources":["../../src/components/MaterialTab.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAgB1B;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IACzC,QAAQ,EAAE,GAAG,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,gBAAgB;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,GAAG,CAAC;IACnB,OAAO,EAAE,GAAG,CAAC;IACb,gBAAgB,EAAE,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IACzC,+EAA+E;IAC/E,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,sBAAsB,EAAE,MAAM,IAAI,CAAC;IACnC;;;;OAIG;IACH,SAAS,CAAC,EAAE,GAAG,EAAE,CAAC;IAClB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,4BAA4B,CAAC,CAAC;CAC/E;AAED,iBAAS,WAAW,CAAC,EACjB,SAAS,EACT,EAAE,EACF,IAAI,EACJ,QAAQ,EACR,KAAK,EACL,MAAM,EACN,aAAa,EACb,OAAO,EACP,gBAAgB,EAChB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,SAAS,EACT,uBAAuB,GAC1B,EAAE,gBAAgB,qBA6DlB;AAED,eAAe,WAAW,CAAC"} \ No newline at end of file diff --git a/dist/components/MaterialTab.js b/dist/components/MaterialTab.js index 79083f1..2690633 100644 --- a/dist/components/MaterialTab.js +++ b/dist/components/MaterialTab.js @@ -11,7 +11,12 @@ function MaterialNameFallback({ material }) { return (_jsxs("div", { style: { padding: "16px" }, children: [_jsx("strong", { children: "Material:" }), " ", name] })); } function MaterialTab({ className, id, role, material, index, length, publicAccount, profile, addRemoveAllowed, onUpdateIndex, onMaterialRemove, openAddMaterialsDialog, materials, MaterialViewerComponent, }) { - const trayMaterials = (materials === null || materials === void 0 ? void 0 : materials.length) ? materials : material ? [material] : []; + // Hosts that pass only the active material still get a one-chip tray. + let trayMaterials = []; + if (materials === null || materials === void 0 ? void 0 : materials.length) + trayMaterials = materials; + else if (material) + trayMaterials = [material]; return (_jsxs("div", { className: setClass(className), id: id, role: role, style: { height: "100%" }, children: [_jsx(MaterialsTray, { materials: trayMaterials, activeIndex: index, onSelect: onUpdateIndex, onRemove: onMaterialRemove, onAdd: openAddMaterialsDialog, editable: Boolean(addRemoveAllowed) }), _jsxs(Box, { sx: { display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(240px, 300px)" }, diff --git a/src/components/MaterialMetadataPanel.tsx b/src/components/MaterialMetadataPanel.tsx index a7a1fb2..6b682a2 100644 --- a/src/components/MaterialMetadataPanel.tsx +++ b/src/components/MaterialMetadataPanel.tsx @@ -48,6 +48,18 @@ function MetadataRow({ label, children }: { label: string; children?: React.Reac * atom count and provenance — the things worth checking before spending * core-hours on a run — could only be found by leaving the designer. */ +/** The source id, linked out when the model knows where it came from. */ +function SourceValue({ source }: { source?: { id: string; url?: string } }) { + if (!source) return null; + if (!source.url) return {source.id}; + + return ( + + {source.id} + + ); +} + export default function MaterialMetadataPanel({ material }: { material: any }) { const summary = getMaterialSummary(material); @@ -66,15 +78,7 @@ export default function MaterialMetadataPanel({ material }: { material: any }) { {summary.spaceGroup} {summary.atomCount} - {summary.source ? ( - summary.source.url ? ( - - {summary.source.id} - - ) : ( - summary.source.id - ) - ) : undefined} + {summary.source ? : undefined} ); diff --git a/src/components/MaterialTab.tsx b/src/components/MaterialTab.tsx index e629745..e4f85bb 100644 --- a/src/components/MaterialTab.tsx +++ b/src/components/MaterialTab.tsx @@ -78,7 +78,10 @@ function MaterialTab({ materials, MaterialViewerComponent, }: MaterialTabProps) { - const trayMaterials = materials?.length ? materials : material ? [material] : []; + // Hosts that pass only the active material still get a one-chip tray. + let trayMaterials: any[] = []; + if (materials?.length) trayMaterials = materials; + else if (material) trayMaterials = [material]; return (
From 3e873b8f063398c1ec39fc02569724af7ba80109 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 22:57:25 +0000 Subject: [PATCH 14/33] feat: say whether the job is saved, and guard leaving (phase 2.6) The designer saves manually but never said whether it needed to: a job with unsaved edits looked exactly like a saved one, and closing the tab lost them silently. The guided-designer mockups show 'All changes saved' in the header - copy that would be worse than the current silence if it were not actually true. - Header indicator: All changes saved / Unsaved changes / Saving, shown only while the job is editable. - beforeunload guard, armed only when leaving would actually lose work - not for read-only jobs, and not mid-save, since warning there teaches people to dismiss the dialog unread. - Both save paths (injected header organism and package-native fallback) now route through one saveJob(), so the flag cannot be cleared by one and missed by the other. Edits are marked in the handlers rather than in persistJob(): that also runs on mount and on entering the Workflow tab, neither of which is an edit. And hasUnsavedChanges joins shouldComponentUpdate - the mixins only consider the job entity, so state this component owns is invisible to them. Explicitly not autosave: that is UX-498's decision and has backend implications. This only stops the interface being quiet about state it already knows. Verified in the demo: 'All changes saved' on load with the guard off; after editing, 'Unsaved changes' with the guard armed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 31 +++++++++++++ dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 71 ++++++++++++++++++++++++++-- dist/saveState.d.ts | 32 +++++++++++++ dist/saveState.d.ts.map | 1 + dist/saveState.js | 36 +++++++++++++++ src/components/Job.jsx | 90 +++++++++++++++++++++++++++++++++--- src/saveState.ts | 56 ++++++++++++++++++++++ tests/saveState.tests.ts | 52 +++++++++++++++++++++ 9 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 dist/saveState.d.ts create mode 100644 dist/saveState.d.ts.map create mode 100644 dist/saveState.js create mode 100644 src/saveState.ts create mode 100644 tests/saveState.tests.ts diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index acb93d9..556846d 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -11,6 +11,13 @@ declare class Job { * Persist in-memory job to Redux; `jobUpdate` runs `job.render()` and bumps `renderGeneration`. */ persistJob(): void; + /** + * Something the reader did changed the job. Deliberately called from the + * handlers rather than from `persistJob()`: that also runs on mount and on + * entering the Workflow tab, neither of which is an edit, and claiming + * unsaved changes for them would make the indicator meaningless. + */ + markUnsavedChanges(): void; get computedEntity(): any; get isUsingDatasetTab(): any; get isUsingMaterial(): any; @@ -18,6 +25,16 @@ declare class Job { get defaultTab(): any; isCurrentTab(tabNameId: any): boolean; componentDidMount(): void; + /** + * Browsers ignore custom text here and show their own wording; setting + * returnValue is what makes the prompt appear at all. + */ + warnIfLeavingWithUnsavedChanges: (event: any) => string; + get saveStateInputs(): { + hasUnsavedChanges: any; + editable: boolean; + isSaving: boolean; + }; componentDidUpdate(prevProps: any): void; componentWillUnmount(): void; shouldComponentUpdate(nextProps: any, nextState: any): any; @@ -40,6 +57,15 @@ declare class Job { onClick: () => void; icon?: undefined; })[]; + /** + * Persists the entity, then clears the unsaved-changes flag. + * + * Both header paths (injected organism and package-native fallback) go + * through here so the indicator cannot be cleared by one and missed by the + * other - and so the flag only drops once the save has actually been handed + * off, not merely requested. + */ + saveJob(save: any, ...args: any[]): void; openTerminateConfirmation: () => any; closeTerminateConfirmation: () => any; confirmTerminate: () => void; @@ -50,6 +76,11 @@ declare class Job { * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Says whether the job on screen has been persisted. Only while editable: + * a read-only view has nothing to save, so the words would be noise. + */ + renderSaveStateIndicator(): React.JSX.Element; renderSubmitAction(): React.JSX.Element; renderTerminateConfirmation(): React.JSX.Element; getSaveBtnProps(): { diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index 6566e3b..f114c5d 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAgIA;IAMI,wBAYC;IAVG,WAMC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAIC;IAdD;;OAEG;IACH,mBAEC;IAWD,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAEC;IAED,yCAOC;IAED,6BAEC;IAED,2DAWC;IAED,kBAAmB,YAAO,UAIxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAIlB;IAEF,eAAgB,WAAM,UAIpB;IAEF,2BAKE;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA8BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAyQC;CACJ;kBAj4BiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAkIA;IAMI,wBAaC;IAXG,WAOC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAYC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BA4QC;CACJ;kBA98BiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index f4dc8f1..c4a3543 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -12,12 +12,14 @@ import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; import lodash from "lodash"; import { mix } from "mixwith"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { getSubmitBlockedReason } from "../jobSubmission"; +import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; import DatasetTab from "./DatasetTab"; @@ -102,9 +104,21 @@ function JobDesignerErrorCard({ error, resetErrorBoundary }) { class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin, StatefulEntityMixin, DescriptionUpdateMixin, ComputableEntityMixin) { constructor(props) { super(props); + /** + * Browsers ignore custom text here and show their own wording; setting + * returnValue is what makes the prompt appear at all. + */ + this.warnIfLeavingWithUnsavedChanges = (event) => { + if (!shouldWarnBeforeLeaving(this.saveStateInputs)) + return undefined; + event.preventDefault(); + event.returnValue = ""; + return ""; + }; this.onComputeUpdate = (compute) => { const job = this.state.entity; job.setCompute(compute); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; this.onDescriptionUpdate = (...args) => this.onDescriptionUpdateGenerator(this.state.entity, this._resetStateEntityAndUpdateParents, () => { @@ -116,16 +130,19 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin this.onNameUpdate = (name) => { const job = this.state.entity; job.setName(name); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; this.setParentJob = (parent) => { const job = this.state.entity; job.setParent(parent); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; this.onParentRemove = () => { const job = this.state.entity; job.unsetParent(); + this.markUnsavedChanges(); // Workaround to propagate changes to component TODO: figure out how to avoid using forceUpdate this._resetStateEntityAndUpdateParents(job); }; @@ -316,6 +333,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin currentTab: this.defaultTab, isWorkflowLoading: false, isTerminateConfirmationOpen: false, + hasUnsavedChanges: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -333,8 +351,19 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin onWorkflowUpdate(workflow) { const job = this.state.entity; job.setWorkflow(workflow); + this.markUnsavedChanges(); this.props.onUpdate(job); } + /** + * Something the reader did changed the job. Deliberately called from the + * handlers rather than from `persistJob()`: that also runs on mount and on + * entering the Workflow tab, neither of which is an edit, and claiming + * unsaved changes for them would make the indicator meaningless. + */ + markUnsavedChanges() { + if (!this.state.hasUnsavedChanges) + this.setState({ hasUnsavedChanges: true }); + } get computedEntity() { return this.state.entity; } @@ -384,6 +413,14 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin } componentDidMount() { this.persistJob(); + window.addEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); + } + get saveStateInputs() { + return { + hasUnsavedChanges: this.state.hasUnsavedChanges, + editable: Boolean(this.props.editable), + isSaving: Boolean(this.props.isLoading), + }; } componentDidUpdate(prevProps) { if (prevProps.job !== this.props.job) { @@ -394,6 +431,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin } } componentWillUnmount() { + window.removeEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); this.props.onDestroy(); } shouldComponentUpdate(nextProps, nextState) { @@ -404,13 +442,28 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // Without this the confirmation never appears: the mixins below only // consider the job entity, so a state change this component owns is // invisible to them and the render is skipped. - this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen); + this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen || + this.state.hasUnsavedChanges !== nextState.hasUnsavedChanges); } renderParentJob() { var _a, _b; const parentJob = (_b = (_a = this.state.entity).getParentJobClient) === null || _b === void 0 ? void 0 : _b.call(_a); return parentJob ? (_jsx(Alert, { severity: "info", onClose: this.props.editable ? this.onParentRemove : undefined, children: _jsxs("div", { className: "search-pill-selected", children: ["Parent job:", " ", _jsx("b", { children: _jsx("a", { href: "", onClick: parentJob.open, children: parentJob.name }) }), " ", "from\u00A0", _jsx("b", { children: parentJob._project.slug }), " project"] }) })) : null; } + /** + * Persists the entity, then clears the unsaved-changes flag. + * + * Both header paths (injected organism and package-native fallback) go + * through here so the indicator cannot be cleared by one and missed by the + * other - and so the flag only drops once the save has actually been handed + * off, not merely requested. + */ + saveJob(save, ...args) { + this._resetStateEntityAndUpdateParents(this.state.entity, () => { + save(...args); + this.setState({ hasUnsavedChanges: false }); + }); + } /** * Submit and Terminate as header buttons rather than dropdown items. * @@ -418,6 +471,16 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Says whether the job on screen has been persisted. Only while editable: + * a read-only view has nothing to save, so the words would be noise. + */ + renderSaveStateIndicator() { + if (!this.props.editable) + return null; + const saveState = getSaveState(this.saveStateInputs); + return (_jsx(Typography, { id: "job-save-state", variant: "caption", color: saveState === "unsaved" ? "warning.main" : "text.secondary", sx: { whiteSpace: "nowrap" }, children: getSaveStateLabel(saveState) })); + } renderSubmitAction() { const job = this.state.entity; const { editable, materials, onSubmit } = this.props; @@ -461,7 +524,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // persist the entity as it was on the very first render (e.g. the // original auto-generated job, before any parent/workflow/materials // selection or rename) — silently reverting all later edits on Save. - this._resetStateEntityAndUpdateParents(this.state.entity, () => this.props.onSave(...args)); + this.saveJob((...saveArgs) => this.props.onSave(...saveArgs), ...args); }, }, ], @@ -537,8 +600,8 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // capture): the organism's ButtonMultiSelect snapshots its configs // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. - onSave: (omitRedirect) => this._resetStateEntityAndUpdateParents(this.state.entity, () => this.props.onSave(omitRedirect)), - }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); + onSave: (omitRedirect) => this.saveJob((redirectFlag) => this.props.onSave(redirectFlag), omitRedirect), + }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); } } export default Job; diff --git a/dist/saveState.d.ts b/dist/saveState.d.ts new file mode 100644 index 0000000..84cbe74 --- /dev/null +++ b/dist/saveState.d.ts @@ -0,0 +1,32 @@ +/** + * Whether the job on screen matches the one that was persisted. + * + * The designer saves manually, but says nothing about whether it needs to: a + * job with unsaved edits looks exactly like a saved one, and closing the tab + * loses them silently. The guided-designer mockups show "All changes saved" in + * the header — copy that would be worse than the current silence if it were not + * actually true, so this tracks the real thing. + * + * Explicitly *not* autosave. Persisting automatically is a product decision + * (UX-498) with its own backend implications; this only stops the interface + * from being quiet about state it already knows. + */ +export type SaveState = "saved" | "unsaved" | "saving"; +export declare function getSaveState({ hasUnsavedChanges, isSaving, }: { + hasUnsavedChanges: boolean; + isSaving?: boolean; +}): SaveState; +export declare function getSaveStateLabel(state: SaveState): string; +/** + * Whether leaving now would lose work. + * + * A read-only view has nothing to lose, and a job mid-save is already on its + * way to the server — warning in either case trains people to dismiss the + * dialog without reading it. + */ +export declare function shouldWarnBeforeLeaving({ hasUnsavedChanges, editable, isSaving, }: { + hasUnsavedChanges: boolean; + editable: boolean; + isSaving?: boolean; +}): boolean; +//# sourceMappingURL=saveState.d.ts.map \ No newline at end of file diff --git a/dist/saveState.d.ts.map b/dist/saveState.d.ts.map new file mode 100644 index 0000000..4fcd51d --- /dev/null +++ b/dist/saveState.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"saveState.d.ts","sourceRoot":"","sources":["../src/saveState.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvD,wBAAgB,YAAY,CAAC,EACzB,iBAAiB,EACjB,QAAgB,GACnB,EAAE;IACC,iBAAiB,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB,GAAG,SAAS,CAIZ;AAQD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAE1D;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,EACpC,iBAAiB,EACjB,QAAQ,EACR,QAAgB,GACnB,EAAE;IACC,iBAAiB,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB,GAAG,OAAO,CAEV"} \ No newline at end of file diff --git a/dist/saveState.js b/dist/saveState.js new file mode 100644 index 0000000..5bbcbcb --- /dev/null +++ b/dist/saveState.js @@ -0,0 +1,36 @@ +/** + * Whether the job on screen matches the one that was persisted. + * + * The designer saves manually, but says nothing about whether it needs to: a + * job with unsaved edits looks exactly like a saved one, and closing the tab + * loses them silently. The guided-designer mockups show "All changes saved" in + * the header — copy that would be worse than the current silence if it were not + * actually true, so this tracks the real thing. + * + * Explicitly *not* autosave. Persisting automatically is a product decision + * (UX-498) with its own backend implications; this only stops the interface + * from being quiet about state it already knows. + */ +export function getSaveState({ hasUnsavedChanges, isSaving = false, }) { + if (isSaving) + return "saving"; + return hasUnsavedChanges ? "unsaved" : "saved"; +} +const SAVE_STATE_LABELS = { + saved: "All changes saved", + unsaved: "Unsaved changes", + saving: "Saving…", +}; +export function getSaveStateLabel(state) { + return SAVE_STATE_LABELS[state]; +} +/** + * Whether leaving now would lose work. + * + * A read-only view has nothing to lose, and a job mid-save is already on its + * way to the server — warning in either case trains people to dismiss the + * dialog without reading it. + */ +export function shouldWarnBeforeLeaving({ hasUnsavedChanges, editable, isSaving = false, }) { + return Boolean(editable) && hasUnsavedChanges && !isSaving; +} diff --git a/src/components/Job.jsx b/src/components/Job.jsx index d7a9ac5..19b716f 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -11,12 +11,14 @@ import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; import lodash from "lodash"; import { mix } from "mixwith"; import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { getSubmitBlockedReason } from "../jobSubmission"; +import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; import DatasetTab from "./DatasetTab"; @@ -140,6 +142,7 @@ class Job extends mix(React.Component).with( currentTab: this.defaultTab, isWorkflowLoading: false, isTerminateConfirmationOpen: false, + hasUnsavedChanges: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -159,9 +162,20 @@ class Job extends mix(React.Component).with( onWorkflowUpdate(workflow) { const job = this.state.entity; job.setWorkflow(workflow); + this.markUnsavedChanges(); this.props.onUpdate(job); } + /** + * Something the reader did changed the job. Deliberately called from the + * handlers rather than from `persistJob()`: that also runs on mount and on + * entering the Workflow tab, neither of which is an edit, and claiming + * unsaved changes for them would make the indicator meaningless. + */ + markUnsavedChanges() { + if (!this.state.hasUnsavedChanges) this.setState({ hasUnsavedChanges: true }); + } + get computedEntity() { return this.state.entity; } @@ -214,6 +228,27 @@ class Job extends mix(React.Component).with( componentDidMount() { this.persistJob(); + window.addEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); + } + + /** + * Browsers ignore custom text here and show their own wording; setting + * returnValue is what makes the prompt appear at all. + */ + warnIfLeavingWithUnsavedChanges = (event) => { + if (!shouldWarnBeforeLeaving(this.saveStateInputs)) return undefined; + + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + + get saveStateInputs() { + return { + hasUnsavedChanges: this.state.hasUnsavedChanges, + editable: Boolean(this.props.editable), + isSaving: Boolean(this.props.isLoading), + }; } componentDidUpdate(prevProps) { @@ -226,6 +261,7 @@ class Job extends mix(React.Component).with( } componentWillUnmount() { + window.removeEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); this.props.onDestroy(); } @@ -238,13 +274,15 @@ class Job extends mix(React.Component).with( // Without this the confirmation never appears: the mixins below only // consider the job entity, so a state change this component owns is // invisible to them and the render is skipped. - this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen + this.state.isTerminateConfirmationOpen !== nextState.isTerminateConfirmationOpen || + this.state.hasUnsavedChanges !== nextState.hasUnsavedChanges ); } onComputeUpdate = (compute) => { const job = this.state.entity; job.setCompute(compute); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; @@ -263,18 +301,21 @@ class Job extends mix(React.Component).with( onNameUpdate = (name) => { const job = this.state.entity; job.setName(name); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; setParentJob = (parent) => { const job = this.state.entity; job.setParent(parent); + this.markUnsavedChanges(); this._resetStateEntityAndUpdateParents(job); }; onParentRemove = () => { const job = this.state.entity; job.unsetParent(); + this.markUnsavedChanges(); // Workaround to propagate changes to component TODO: figure out how to avoid using forceUpdate this._resetStateEntityAndUpdateParents(job); }; @@ -342,6 +383,21 @@ class Job extends mix(React.Component).with( return actions; }; + /** + * Persists the entity, then clears the unsaved-changes flag. + * + * Both header paths (injected organism and package-native fallback) go + * through here so the indicator cannot be cleared by one and missed by the + * other - and so the flag only drops once the save has actually been handed + * off, not merely requested. + */ + saveJob(save, ...args) { + this._resetStateEntityAndUpdateParents(this.state.entity, () => { + save(...args); + this.setState({ hasUnsavedChanges: false }); + }); + } + openTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: true }); closeTerminateConfirmation = () => this.setState({ isTerminateConfirmationOpen: false }); @@ -358,6 +414,27 @@ class Job extends mix(React.Component).with( * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Says whether the job on screen has been persisted. Only while editable: + * a read-only view has nothing to save, so the words would be noise. + */ + renderSaveStateIndicator() { + if (!this.props.editable) return null; + + const saveState = getSaveState(this.saveStateInputs); + + return ( + + {getSaveStateLabel(saveState)} + + ); + } + renderSubmitAction() { const job = this.state.entity; const { editable, materials, onSubmit } = this.props; @@ -451,9 +528,7 @@ class Job extends mix(React.Component).with( // persist the entity as it was on the very first render (e.g. the // original auto-generated job, before any parent/workflow/materials // selection or rename) — silently reverting all later edits on Save. - this._resetStateEntityAndUpdateParents(this.state.entity, () => - this.props.onSave(...args), - ); + this.saveJob((...saveArgs) => this.props.onSave(...saveArgs), ...args); }, }, ], @@ -750,8 +825,9 @@ class Job extends mix(React.Component).with( // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. onSave: (omitRedirect) => - this._resetStateEntityAndUpdateParents(this.state.entity, () => - this.props.onSave(omitRedirect), + this.saveJob( + (redirectFlag) => this.props.onSave(redirectFlag), + omitRedirect, ), }} dropdownProps={dropdownProps} @@ -761,6 +837,7 @@ class Job extends mix(React.Component).with( isDescriptionEditable={isDescriptionEditable} onDescriptionUpdate={this.onDescriptionUpdate} > + {this.renderSaveStateIndicator()} {this.renderSubmitAction()} {headerChildren ?? null} @@ -780,6 +857,7 @@ class Job extends mix(React.Component).with( empty menu. */} {dropdownProps.isShown && } {this.props.editable && } + {this.renderSaveStateIndicator()} {this.renderSubmitAction()} {headerChildren ?? null} diff --git a/src/saveState.ts b/src/saveState.ts new file mode 100644 index 0000000..365ae31 --- /dev/null +++ b/src/saveState.ts @@ -0,0 +1,56 @@ +/** + * Whether the job on screen matches the one that was persisted. + * + * The designer saves manually, but says nothing about whether it needs to: a + * job with unsaved edits looks exactly like a saved one, and closing the tab + * loses them silently. The guided-designer mockups show "All changes saved" in + * the header — copy that would be worse than the current silence if it were not + * actually true, so this tracks the real thing. + * + * Explicitly *not* autosave. Persisting automatically is a product decision + * (UX-498) with its own backend implications; this only stops the interface + * from being quiet about state it already knows. + */ + +export type SaveState = "saved" | "unsaved" | "saving"; + +export function getSaveState({ + hasUnsavedChanges, + isSaving = false, +}: { + hasUnsavedChanges: boolean; + isSaving?: boolean; +}): SaveState { + if (isSaving) return "saving"; + + return hasUnsavedChanges ? "unsaved" : "saved"; +} + +const SAVE_STATE_LABELS: Record = { + saved: "All changes saved", + unsaved: "Unsaved changes", + saving: "Saving…", +}; + +export function getSaveStateLabel(state: SaveState): string { + return SAVE_STATE_LABELS[state]; +} + +/** + * Whether leaving now would lose work. + * + * A read-only view has nothing to lose, and a job mid-save is already on its + * way to the server — warning in either case trains people to dismiss the + * dialog without reading it. + */ +export function shouldWarnBeforeLeaving({ + hasUnsavedChanges, + editable, + isSaving = false, +}: { + hasUnsavedChanges: boolean; + editable: boolean; + isSaving?: boolean; +}): boolean { + return Boolean(editable) && hasUnsavedChanges && !isSaving; +} diff --git a/tests/saveState.tests.ts b/tests/saveState.tests.ts new file mode 100644 index 0000000..67a0589 --- /dev/null +++ b/tests/saveState.tests.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../src/saveState"; + +describe("getSaveState", () => { + it("reports what is actually true of the entity", () => { + assert.equal(getSaveState({ hasUnsavedChanges: false }), "saved"); + assert.equal(getSaveState({ hasUnsavedChanges: true }), "unsaved"); + }); + + it("prefers the in-flight state over both", () => { + // Mid-save the honest answer is neither "saved" nor "unsaved". + assert.equal(getSaveState({ hasUnsavedChanges: true, isSaving: true }), "saving"); + assert.equal(getSaveState({ hasUnsavedChanges: false, isSaving: true }), "saving"); + }); +}); + +describe("getSaveStateLabel", () => { + it("never claims saved for a dirty job", () => { + // The whole point: the mockups' "All changes saved" is only allowed to + // appear when it is true. + assert.equal( + getSaveStateLabel(getSaveState({ hasUnsavedChanges: true })), + "Unsaved changes", + ); + assert.equal( + getSaveStateLabel(getSaveState({ hasUnsavedChanges: false })), + "All changes saved", + ); + }); +}); + +describe("shouldWarnBeforeLeaving", () => { + it("warns when leaving would lose edits", () => { + assert.equal(shouldWarnBeforeLeaving({ hasUnsavedChanges: true, editable: true }), true); + }); + + it("stays quiet when there is nothing to lose", () => { + assert.equal(shouldWarnBeforeLeaving({ hasUnsavedChanges: false, editable: true }), false); + // A shared or finished job cannot be edited, so nothing is at risk. + assert.equal(shouldWarnBeforeLeaving({ hasUnsavedChanges: true, editable: false }), false); + }); + + it("stays quiet mid-save, which is already on its way to the server", () => { + // Warning here trains people to dismiss the dialog without reading it. + assert.equal( + shouldWarnBeforeLeaving({ hasUnsavedChanges: true, editable: true, isSaving: true }), + false, + ); + }); +}); From f76414bc453d1388e0497e67016036092af4da1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 22:58:01 +0000 Subject: [PATCH 15/33] docs: record 2.5 and 2.6 as built, and what gates the rest of phase 2 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...-08-16-Job-Designer-Guided-Designer-Plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index ec25dcd..27fe0f1 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -147,6 +147,14 @@ folder's own convention asks — this overview moves last. ## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) +> **Progress (2026-08-16):** 2.5 and 2.6 are **built and in review** on +> [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) — they are job-designer-local +> and needed no new cove primitives, so they were taken out of order. 2.1–2.4 remain gated: +> the readiness rail and context strip consume `StatusChip` from +> [cove#97](https://github.com/mat3ra/cove/pull/97), and the estimator half of 2.3 is blocked on +> open question 1 (where pricing and quota live). The limits/validation half of 2.3 can proceed +> without that answer. + The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, `03-preflight-submit.html`. @@ -241,6 +249,12 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, `Material` model next to the injected viewer; the viewer component API is unchanged. - Acceptance: adding a second material updates the tray, the batch copy, and the context strip; removing the active material selects a sane neighbor. Size: M. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Divergence: + the metadata is read through a defensive `getMaterialSummary()` that omits any field it + cannot read, because `MaterialTab` already renders a fallback for hosts passing a plain + config and made's model getters throw on partial data. Space group is shown only when the + model actually carries a `symmetry` derived property — standata materials generally do not, + so the row is usually absent rather than guessed from the name. ### 2.6 Save-state honesty (new; relates to UX-498) @@ -254,6 +268,10 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, - Acceptance: editing any field flips the indicator to dirty; Save flips it back; closing the tab with a dirty draft warns; the indicator never claims "Saved" while in-memory state differs from the persisted entity. Size: S–M. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Dirty state is + marked in the mutating handlers rather than in `persistJob()`, which also runs on mount and + on entering the Workflow tab; and both save paths were routed through a single `saveJob()` + so the flag cannot be cleared by one header and missed by the other. ## Phase 3 — The living job (proposals F1, F2, C2, D3, D4) From 0780ad0fd3e2c804a42a501ee947aa41655469c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 23:05:45 +0000 Subject: [PATCH 16/33] feat: readiness rail and context strip (phase 2.1, 2.2) The numbered tabs implied a sequence while looking identical whether a step was done or untouched, and the actions that complete them lived in a dropdown. The rail shows each step's state and what is chosen; the strip carries those selections onto every screen, so checking which material a job runs on no longer means leaving the step you are on. getJobReadiness is the single source of truth - the rail, the strip, the Submit button and (next) the preflight all read it, so they cannot disagree about whether a job is ready. It covers the shapes the plan called out: material jobs, dataset jobs, multi-material sets, and parent-derived jobs, where the parent supplies the material and the job must not read as 'no material selected'. After submission the creation steps stop being things to do and Monitor/Files take over, replacing the status-based tab jumping in defaultTab. It is a pure derivation that never touches the entity - a test asserts it does not, because Job.jsx runs job.render() from persistJob() and a compute keystroke must not drag a workflow re-render along. The parent job moves into the strip as a chip: it used to be a dismissable Alert above the tabs, which spent a lot of screen on one fact and put 'unset parent' behind an X that reads as 'hide this message'. Behind useGuidedDesigner (default off), per the plan's rollout. The demo has a toggle so both layouts stay reviewable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 10 + dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 48 +++- dist/components/JobContextStrip.d.ts | 25 ++ dist/components/JobContextStrip.d.ts.map | 1 + dist/components/JobContextStrip.js | 26 ++ dist/components/JobReadinessRail.d.ts | 22 ++ dist/components/JobReadinessRail.d.ts.map | 1 + dist/components/JobReadinessRail.js | 64 +++++ dist/containers/JobLocalReduxContainer.d.ts | 6 + .../JobLocalReduxContainer.d.ts.map | 2 +- dist/containers/JobLocalReduxContainer.js | 4 +- dist/jobReadiness.d.ts | 62 +++++ dist/jobReadiness.d.ts.map | 1 + dist/jobReadiness.js | 125 ++++++++++ dist/standalone/index.js | 6 +- src/components/Job.jsx | 83 ++++++- src/components/JobContextStrip.tsx | 104 ++++++++ src/components/JobReadinessRail.tsx | 132 ++++++++++ src/containers/JobLocalReduxContainer.tsx | 8 + src/jobReadiness.ts | 225 ++++++++++++++++++ src/standalone/index.tsx | 12 + tests/jobReadiness.tests.ts | 147 ++++++++++++ 23 files changed, 1101 insertions(+), 15 deletions(-) create mode 100644 dist/components/JobContextStrip.d.ts create mode 100644 dist/components/JobContextStrip.d.ts.map create mode 100644 dist/components/JobContextStrip.js create mode 100644 dist/components/JobReadinessRail.d.ts create mode 100644 dist/components/JobReadinessRail.d.ts.map create mode 100644 dist/components/JobReadinessRail.js create mode 100644 dist/jobReadiness.d.ts create mode 100644 dist/jobReadiness.d.ts.map create mode 100644 dist/jobReadiness.js create mode 100644 src/components/JobContextStrip.tsx create mode 100644 src/components/JobReadinessRail.tsx create mode 100644 src/jobReadiness.ts create mode 100644 tests/jobReadiness.tests.ts diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index 556846d..dd99545 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -30,6 +30,16 @@ declare class Job { * returnValue is what makes the prompt appear at all. */ warnIfLeavingWithUnsavedChanges: (event: any) => string; + /** + * Pure derivation - no entity mutation, and in particular no `job.render()`. + * Recomputed per render rather than memoised: it walks a handful of arrays, + * whereas caching it would mean tracking invalidation across the same + * in-place model mutations that already make this component hard to reason + * about. + */ + get jobReadiness(): import("../jobReadiness").JobReadiness; + /** The rail's Review step has no tab of its own; it lands on Compute. */ + onReadinessStepSelect: (stepId: any) => void; get saveStateInputs(): { hasUnsavedChanges: any; editable: boolean; diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index f114c5d..ea5c33f 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAkIA;IAMI,wBAaC;IAXG,WAOC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAYC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAgBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BA4QC;CACJ;kBA98BiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAqIA;IAMI,wBAaC;IAXG,WAOC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DAQC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAYC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCA6CC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAqTC;CACJ;kBAnhCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index c4a3543..862bfe4 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -19,9 +19,12 @@ import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { getSubmitBlockedReason } from "../jobSubmission"; +import { getJobReadiness } from "../jobReadiness"; import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; +import JobContextStrip from "./JobContextStrip"; +import JobReadinessRail from "./JobReadinessRail"; import DatasetTab from "./DatasetTab"; import FilesTab from "./FilesTab"; import MaterialTab from "./MaterialTab"; @@ -115,6 +118,10 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin event.returnValue = ""; return ""; }; + /** The rail's Review step has no tab of its own; it lands on Compute. */ + this.onReadinessStepSelect = (stepId) => { + this.setCurrentTab(stepId === "review" ? TAB_NAVIGATION_CONFIG.compute.id : stepId); + }; this.onComputeUpdate = (compute) => { const job = this.state.entity; job.setCompute(compute); @@ -415,6 +422,23 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin this.persistJob(); window.addEventListener("beforeunload", this.warnIfLeavingWithUnsavedChanges); } + /** + * Pure derivation - no entity mutation, and in particular no `job.render()`. + * Recomputed per render rather than memoised: it walks a handful of arrays, + * whereas caching it would mean tracking invalidation across the same + * in-place model mutations that already make this component hard to reason + * about. + */ + get jobReadiness() { + var _a; + return getJobReadiness({ + job: this.state.entity, + materials: (_a = this.props.materials) !== null && _a !== void 0 ? _a : [], + isUsingMaterials: this.isUsingMaterialsTab, + datasetConfig: this.props.datasetConfig, + editable: Boolean(this.props.editable), + }); + } get saveStateInputs() { return { hasUnsavedChanges: this.state.hasUnsavedChanges, @@ -447,6 +471,9 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin } renderParentJob() { var _a, _b; + // The context strip carries the parent as a chip; two of them is one too many. + if (this.props.useGuidedDesigner) + return null; const parentJob = (_b = (_a = this.state.entity).getParentJobClient) === null || _b === void 0 ? void 0 : _b.call(_a); return parentJob ? (_jsx(Alert, { severity: "info", onClose: this.props.editable ? this.onParentRemove : undefined, children: _jsxs("div", { className: "search-pill-selected", children: ["Parent job:", " ", _jsx("b", { children: _jsx("a", { href: "", onClick: parentJob.open, children: parentJob.name }) }), " ", "from\u00A0", _jsx("b", { children: parentJob._project.slug }), " project"] }) })) : null; } @@ -552,7 +579,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin closeSelectParentJobDialog(); } render() { - var _a; + var _a, _b, _c; const { editable, isLoading, hideDescription, material, index, length, onUpdateIndex, onMaterialRemove, datasetConfig, allowedWorkflows, onWorkflowSelect, materials, materialsSet, metaProperties, onIsMultiMaterialChanged, onMaterialSwitch, onOutputUpdateRequest, clusters, profile, accountUsers, accountUsersIsLoading, workflowDialogs, publicAccount, project, templates, resultsProperties, jobProperties, createMetaProperty, fetchMaterials, renderGeneration, MaterialViewerComponent, /** Optional children rendered in the right side of the EntityHeader (selectors, export button, etc.). */ headerChildren, } = this.props; @@ -585,6 +612,14 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin }; }); const activeTabIndex = tabsToRender.findIndex((item) => item.id === this.state.currentTab); + // Phase 2 layout is opt-in per host so the webapp and the demo can flip + // independently; the legacy tab strip stays until parity is verified. + const useGuidedDesigner = Boolean(this.props.useGuidedDesigner); + const readiness = this.jobReadiness; + const parentJobClient = (_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job); + const parentJobForStrip = parentJobClient + ? { name: parentJobClient.name, projectSlug: (_b = parentJobClient._project) === null || _b === void 0 ? void 0 : _b.slug } + : null; const isDescriptionEditable = this.isDescriptionEditable(job); const isDesignerLoading = isLoading || this.state.isWorkflowLoading; const dropdownProps = this.getDropdownProps(); @@ -601,7 +636,16 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. onSave: (omitRedirect) => this.saveJob((redirectFlag) => this.props.onSave(redirectFlag), omitRedirect), - }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), _jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true }), _jsx(Box, { children: _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) }) })] })); + }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), useGuidedDesigner ? (_jsx(JobContextStrip, { steps: readiness.steps, onSelect: this.onReadinessStepSelect, parentJob: parentJobForStrip, onParentRemove: editable ? this.onParentRemove : undefined })) : null, useGuidedDesigner ? null : (_jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true })), _jsxs(Box, { sx: useGuidedDesigner + ? { + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "auto minmax(0, 1fr)" }, + alignItems: "start", + } + : undefined, children: [useGuidedDesigner ? (_jsx(JobReadinessRail, { steps: readiness.steps, activeStepId: this.state.currentTab === TAB_NAVIGATION_CONFIG.compute.id && + readiness.isSubmittable + ? TAB_NAVIGATION_CONFIG.compute.id + : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) })] })] })); } } export default Job; diff --git a/dist/components/JobContextStrip.d.ts b/dist/components/JobContextStrip.d.ts new file mode 100644 index 0000000..41e67dd --- /dev/null +++ b/dist/components/JobContextStrip.d.ts @@ -0,0 +1,25 @@ +import React from "react"; +import type { ReadinessStep } from "../jobReadiness"; +export interface JobContextStripProps { + steps: ReadinessStep[]; + onSelect: (stepId: string) => void; + /** Parent job, when this job derives from one. Removable while editable. */ + parentJob?: { + name?: string; + projectSlug?: string; + } | null; + onParentRemove?: () => void; +} +/** + * The job's selections, visible from every step. + * + * Checking which material a job will run on used to mean leaving the step you + * were on — the Compute tab showed no trace of the material or workflow, and + * vice versa. Each chip is also the way back to the step that owns it. + * + * The parent job lives here too. It used to be a dismissable `Alert` sitting + * above the tabs, which is a lot of screen for one fact and put a destructive + * "unset parent" behind an X that reads as "hide this message". + */ +export default function JobContextStrip({ steps, onSelect, parentJob, onParentRemove, }: JobContextStripProps): React.JSX.Element; +//# sourceMappingURL=JobContextStrip.d.ts.map \ No newline at end of file diff --git a/dist/components/JobContextStrip.d.ts.map b/dist/components/JobContextStrip.d.ts.map new file mode 100644 index 0000000..e737204 --- /dev/null +++ b/dist/components/JobContextStrip.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"JobContextStrip.d.ts","sourceRoot":"","sources":["../../src/components/JobContextStrip.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD,MAAM,WAAW,oBAAoB;IACjC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,4EAA4E;IAC5E,SAAS,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3D,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;CAC/B;AAKD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EACpC,KAAK,EACL,QAAQ,EACR,SAAS,EACT,cAAc,GACjB,EAAE,oBAAoB,qBAqEtB"} \ No newline at end of file diff --git a/dist/components/JobContextStrip.js b/dist/components/JobContextStrip.js new file mode 100644 index 0000000..8035f91 --- /dev/null +++ b/dist/components/JobContextStrip.js @@ -0,0 +1,26 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +/** Steps whose selections are worth carrying on every screen. */ +const CONTEXT_STEP_IDS = new Set(["material", "dataset", "workflow", "compute"]); +/** + * The job's selections, visible from every step. + * + * Checking which material a job will run on used to mean leaving the step you + * were on — the Compute tab showed no trace of the material or workflow, and + * vice versa. Each chip is also the way back to the step that owns it. + * + * The parent job lives here too. It used to be a dismissable `Alert` sitting + * above the tabs, which is a lot of screen for one fact and put a destructive + * "unset parent" behind an X that reads as "hide this message". + */ +export default function JobContextStrip({ steps, onSelect, parentJob, onParentRemove, }) { + const contextSteps = steps.filter((step) => CONTEXT_STEP_IDS.has(step.id)); + if (!contextSteps.length && !parentJob) + return null; + return (_jsxs(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", useFlexGap: true, alignItems: "center", id: "job-context-strip", sx: { px: 2, py: 1, borderBottom: "1px solid", borderColor: "divider" }, children: [contextSteps.map((step) => { + const needsAttention = step.state !== "complete"; + return (_jsx(Chip, { id: `job-context-${step.id}`, size: "small", variant: "outlined", color: needsAttention ? "warning" : "default", onClick: () => onSelect(step.id), label: _jsxs(Stack, { direction: "row", spacing: 0.75, alignItems: "baseline", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { textTransform: "uppercase", letterSpacing: ".06em" }, children: step.label }), _jsx(Typography, { variant: "caption", fontWeight: 600, children: step.summary })] }) }, step.id)); + }), parentJob ? (_jsx(Chip, { id: "job-context-parent", size: "small", variant: "outlined", color: "info", onDelete: onParentRemove, label: _jsxs(Stack, { direction: "row", spacing: 0.75, alignItems: "baseline", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { textTransform: "uppercase", letterSpacing: ".06em" }, children: "Parent" }), _jsxs(Typography, { variant: "caption", fontWeight: 600, children: [parentJob.name, parentJob.projectSlug ? ` · ${parentJob.projectSlug}` : ""] })] }) })) : null] })); +} diff --git a/dist/components/JobReadinessRail.d.ts b/dist/components/JobReadinessRail.d.ts new file mode 100644 index 0000000..313f7ad --- /dev/null +++ b/dist/components/JobReadinessRail.d.ts @@ -0,0 +1,22 @@ +import React from "react"; +import type { ReadinessStep } from "../jobReadiness"; +export interface JobReadinessRailProps { + steps: ReadinessStep[]; + activeStepId: string; + onSelect: (stepId: string) => void; + /** Rendered under the steps — parent job, import, and other power actions. */ + children?: React.ReactNode; +} +/** + * The job's lifecycle, with its current state on show. + * + * Replaces the numbered tab strip. Those tabs implied a sequence — "1. Materials, + * 2. Workflow, 3. Compute" — while looking identical whether a step was done or + * untouched, and the actions that complete them lived in a dropdown. Each row + * here carries its own state and a line saying what is chosen. + * + * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, + * and the active one carries `aria-current`. + */ +export default function JobReadinessRail({ steps, activeStepId, onSelect, children, }: JobReadinessRailProps): React.JSX.Element; +//# sourceMappingURL=JobReadinessRail.d.ts.map \ No newline at end of file diff --git a/dist/components/JobReadinessRail.d.ts.map b/dist/components/JobReadinessRail.d.ts.map new file mode 100644 index 0000000..e471ce4 --- /dev/null +++ b/dist/components/JobReadinessRail.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"JobReadinessRail.d.ts","sourceRoot":"","sources":["../../src/components/JobReadinessRail.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAarE,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACrC,KAAK,EACL,YAAY,EACZ,QAAQ,EACR,QAAQ,GACX,EAAE,qBAAqB,qBAuFvB"} \ No newline at end of file diff --git a/dist/components/JobReadinessRail.js b/dist/components/JobReadinessRail.js new file mode 100644 index 0000000..acf9e8d --- /dev/null +++ b/dist/components/JobReadinessRail.js @@ -0,0 +1,64 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import ButtonBase from "@mui/material/ButtonBase"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; +/** Icon and tone per step state. Never color alone — every state has a glyph. */ +const STATE_PRESENTATION = { + complete: { iconName: "shapes.check", color: "success.main" }, + attention: { iconName: "shapes.info", color: "warning.main" }, + empty: { iconName: "shapes.circle", color: "text.disabled", isMuted: true }, + unavailable: { iconName: "shapes.lock", color: "text.disabled", isMuted: true }, +}; +/** + * The job's lifecycle, with its current state on show. + * + * Replaces the numbered tab strip. Those tabs implied a sequence — "1. Materials, + * 2. Workflow, 3. Compute" — while looking identical whether a step was done or + * untouched, and the actions that complete them lived in a dropdown. Each row + * here carries its own state and a line saying what is chosen. + * + * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, + * and the active one carries `aria-current`. + */ +export default function JobReadinessRail({ steps, activeStepId, onSelect, children, }) { + const stepRefs = React.useRef([]); + const handleKeyDown = (event, index) => { + var _a; + const offset = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key]; + if (!offset) + return; + event.preventDefault(); + const nextIndex = (index + offset + steps.length) % steps.length; + (_a = stepRefs.current[nextIndex]) === null || _a === void 0 ? void 0 : _a.focus(); + }; + return (_jsxs(Stack, { component: "nav", "aria-label": "Job steps", id: "job-readiness-rail", sx: { + borderRight: { md: "1px solid" }, + borderBottom: { xs: "1px solid", md: "none" }, + borderColor: { xs: "divider", md: "divider" }, + p: 1, + minWidth: { md: 260 }, + gap: 0.25, + }, children: [steps.map((step, index) => { + const presentation = STATE_PRESENTATION[step.state]; + const isActive = step.id === activeStepId; + return (_jsxs(ButtonBase, { ref: (node) => { + stepRefs.current[index] = node; + }, id: `job-step-${step.id}`, "aria-current": isActive ? "step" : undefined, disabled: step.state === "unavailable", onClick: () => onSelect(step.id), onKeyDown: (event) => handleKeyDown(event, index), sx: { + justifyContent: "flex-start", + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: 1, + textAlign: "left", + bgcolor: isActive ? "action.selected" : "transparent", + "&:hover": { bgcolor: "action.hover" }, + "&.Mui-focusVisible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }, children: [_jsx(IconByName, { name: presentation.iconName, fontSize: "small", sx: { color: presentation.color } }), _jsxs(Box, { sx: { minWidth: 0 }, children: [_jsx(Typography, { variant: "body2", fontWeight: 600, noWrap: true, children: step.label }), _jsx(Typography, { variant: "caption", color: step.state === "attention" ? "warning.main" : "text.secondary", noWrap: true, sx: { display: "block" }, children: step.summary })] })] }, step.id)); + }), children ? (_jsx(Box, { sx: { mt: 1, pt: 1, borderTop: "1px solid", borderColor: "divider" }, children: children })) : null] })); +} diff --git a/dist/containers/JobLocalReduxContainer.d.ts b/dist/containers/JobLocalReduxContainer.d.ts index dc7abe9..430ba78 100644 --- a/dist/containers/JobLocalReduxContainer.d.ts +++ b/dist/containers/JobLocalReduxContainer.d.ts @@ -87,6 +87,12 @@ interface JobStoreLocalReduxContainerProps { headerChildren?: React.ReactNode; /** Whether the job is editable. */ editable?: boolean; + /** + * Opt into the guided layout (readiness rail + context strip) instead of the + * numbered tab strip. Off by default so hosts flip it when they are ready; + * the legacy path stays until parity is verified. + */ + useGuidedDesigner?: boolean; } type JobLocalReduxContainerProps = JobStoreLocalReduxContainerProps & { jobMaterials: any[]; diff --git a/dist/containers/JobLocalReduxContainer.d.ts.map b/dist/containers/JobLocalReduxContainer.d.ts.map index ed61ad0..7f37fc8 100644 --- a/dist/containers/JobLocalReduxContainer.d.ts.map +++ b/dist/containers/JobLocalReduxContainer.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"JobLocalReduxContainer.d.ts","sourceRoot":"","sources":["../../src/containers/JobLocalReduxContainer.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAgD,MAAM,OAAO,CAAC;AAerE,UAAU,eAAe;IACrB,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACjF;AAED,UAAU,kBAAkB;IACxB,IAAI,EAAE,eAAe,CAAC;IACtB,eAAe,EAAE,kBAAkB,CAAC;IACpC,OAAO,EAAE,kBAAkB,CAAC;CAC/B;AAED,UAAU,kBAAkB;IACxB,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxD;AAED,UAAU,kBAAkB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,uBAAuB;IAC7B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mBAAmB;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,sBAAsB;IAC5B,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC/B,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,yBAAyB;IAC/B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mCAAmC;IACzC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mCAAmC;IACzC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,gCAAgC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,GAAG,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,GAAG,EAAE,CAAC;IACjB,cAAc,EAAE,uBAAuB,EAAE,CAAC;IAC1C,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,qBAAqB,EAAE,OAAO,CAAC;IAC/B,OAAO,EAAE,kBAAkB,CAAC;IAC5B,aAAa,EAAE,kBAAkB,CAAC;IAClC,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC/C,UAAU,EAAE;QACR,0BAA0B,EAAE,sBAAsB,CAAC;QACnD,6BAA6B,EAAE,sBAAsB,CAAC;QACtD,yBAAyB,EAAE,sBAAsB,CAAC;QAClD,yBAAyB,EAAE,sBAAsB,CAAC;KACrD,CAAC;IACF,eAAe,EAAE;QACb,uBAAuB,EAAE,sBAAsB,CAAC;QAChD,mBAAmB,EAAE,sBAAsB,CAAC;KAC/C,CAAC;IACF,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,iBAAiB,EAAE,YAAY,EAAE,CAAC;IAClC,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,kBAAkB,EAAE,CAChB,QAAQ,EAAE,mCAAmC,KAC5C,OAAO,CAAC,mCAAmC,GAAG,SAAS,CAAC,CAAC;IAC9D,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAAC;IACxE,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,EAAE,QAAQ,CAAC,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC7D,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC/C,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAClD,sFAAsF;IACtF,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;QAAE,QAAQ,EAAE,GAAG,CAAA;KAAE,CAAC,CAAC;IACjE,kGAAkG;IAClG,cAAc,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACjC,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AAgND,KAAK,2BAA2B,GAAG,gCAAgC,GAAG;IAClE,YAAY,EAAE,GAAG,EAAE,CAAC;IACpB,qGAAqG;IACrG,QAAQ,CAAC,EAAE,GAAG,CAAC;IACf,sBAAsB,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,GAAG,SAAS,CAAC,CAAC;CAC5E,CAAC;AAEF,iBAAS,sBAAsB,CAAC,KAAK,EAAE,2BAA2B,qBAkBjE;;AAED,wBAA4C"} \ No newline at end of file +{"version":3,"file":"JobLocalReduxContainer.d.ts","sourceRoot":"","sources":["../../src/containers/JobLocalReduxContainer.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,KAAgD,MAAM,OAAO,CAAC;AAerE,UAAU,eAAe;IACrB,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACjF;AAED,UAAU,kBAAkB;IACxB,IAAI,EAAE,eAAe,CAAC;IACtB,eAAe,EAAE,kBAAkB,CAAC;IACpC,OAAO,EAAE,kBAAkB,CAAC;CAC/B;AAED,UAAU,kBAAkB;IACxB,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxD;AAED,UAAU,kBAAkB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,uBAAuB;IAC7B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mBAAmB;IACzB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,sBAAsB;IAC5B,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC/B,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,yBAAyB;IAC/B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mCAAmC;IACzC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,mCAAmC;IACzC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,UAAU,gCAAgC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,GAAG,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,GAAG,EAAE,CAAC;IACjB,cAAc,EAAE,uBAAuB,EAAE,CAAC;IAC1C,YAAY,EAAE,eAAe,EAAE,CAAC;IAChC,qBAAqB,EAAE,OAAO,CAAC;IAC/B,OAAO,EAAE,kBAAkB,CAAC;IAC5B,aAAa,EAAE,kBAAkB,CAAC;IAClC,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC/C,UAAU,EAAE;QACR,0BAA0B,EAAE,sBAAsB,CAAC;QACnD,6BAA6B,EAAE,sBAAsB,CAAC;QACtD,yBAAyB,EAAE,sBAAsB,CAAC;QAClD,yBAAyB,EAAE,sBAAsB,CAAC;KACrD,CAAC;IACF,eAAe,EAAE;QACb,uBAAuB,EAAE,sBAAsB,CAAC;QAChD,mBAAmB,EAAE,sBAAsB,CAAC;KAC/C,CAAC;IACF,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,iBAAiB,EAAE,YAAY,EAAE,CAAC;IAClC,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,kBAAkB,EAAE,CAChB,QAAQ,EAAE,mCAAmC,KAC5C,OAAO,CAAC,mCAAmC,GAAG,SAAS,CAAC,CAAC;IAC9D,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAAC;IACxE,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,EAAE,QAAQ,CAAC,EAAE,GAAG,EAAE,KAAK,IAAI,CAAC;IAC7D,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC/C,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;IAClD,sFAAsF;IACtF,uBAAuB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;QAAE,QAAQ,EAAE,GAAG,CAAA;KAAE,CAAC,CAAC;IACjE,kGAAkG;IAClG,cAAc,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACjC,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAkND,KAAK,2BAA2B,GAAG,gCAAgC,GAAG;IAClE,YAAY,EAAE,GAAG,EAAE,CAAC;IACpB,qGAAqG;IACrG,QAAQ,CAAC,EAAE,GAAG,CAAC;IACf,sBAAsB,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,GAAG,SAAS,CAAC,CAAC;CAC5E,CAAC;AAEF,iBAAS,sBAAsB,CAAC,KAAK,EAAE,2BAA2B,qBAkBjE;;AAED,wBAA4C"} \ No newline at end of file diff --git a/dist/containers/JobLocalReduxContainer.js b/dist/containers/JobLocalReduxContainer.js index 6966411..45787f3 100644 --- a/dist/containers/JobLocalReduxContainer.js +++ b/dist/containers/JobLocalReduxContainer.js @@ -9,7 +9,7 @@ import { useJobDesignerDeps } from "../JobDesignerContext"; import { createJobDesignerReducer } from "../reducers"; import JobContainer from "./JobContainer"; import { JobDesignerReduxContext, useJobDesignerDispatch, useJobDesignerSelector, } from "./JobDesignerReduxContext"; -function JobStoreLocalReduxContainer({ jobId, workflowId, materials, job, project, publicAccount, metaProperties, accountUsers, accountUsersIsLoading, profile, clusters, refreshMetaProperties, jobDialogs, workflowDialogs, templates, resultsProperties, jobProperties, createMetaProperty, fetchMaterials, loadWorkflowEntityById, onMaterialAdd, onMaterialRemove, onDestroy, getJobMaterialClient, MaterialViewerComponent, headerChildren, editable, }) { +function JobStoreLocalReduxContainer({ jobId, workflowId, materials, job, project, publicAccount, metaProperties, accountUsers, accountUsersIsLoading, profile, clusters, refreshMetaProperties, jobDialogs, workflowDialogs, templates, resultsProperties, jobProperties, createMetaProperty, fetchMaterials, loadWorkflowEntityById, onMaterialAdd, onMaterialRemove, onDestroy, getJobMaterialClient, MaterialViewerComponent, headerChildren, editable, useGuidedDesigner, }) { const dispatch = useJobDesignerDispatch(); const stateMaterials = useJobDesignerSelector((state) => state.materials); const stateMaterial = useJobDesignerSelector((state) => state.material); @@ -114,7 +114,7 @@ function JobStoreLocalReduxContainer({ jobId, workflowId, materials, job, projec return; handleWorkflowSelect(workflowId).catch(console.error); }, [jobId, workflowId]); - return (_jsx(JobContainer, { project: project, publicAccount: publicAccount, metaProperties: metaProperties, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, profile: profile, clusters: clusters, jobDialogs: jobDialogs, workflowDialogs: workflowDialogs, templates: templates, resultsProperties: resultsProperties, jobProperties: jobProperties, createMetaProperty: createMetaProperty, fetchMaterials: fetchMaterials, onMaterialAdd: onMaterialAdd, onMaterialRemove: onMaterialRemove, onDestroy: onDestroy, getJobMaterialClient: getJobMaterialClient, onWorkflowSelect: handleWorkflowSelect, onWorkflowUpdate: syncWorkflowWithJob, getRouteQueryTab: getRouteQueryTab, MaterialViewerComponent: MaterialViewerComponent, headerChildren: headerChildren, editable: editable })); + return (_jsx(JobContainer, { project: project, publicAccount: publicAccount, metaProperties: metaProperties, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, profile: profile, clusters: clusters, jobDialogs: jobDialogs, workflowDialogs: workflowDialogs, templates: templates, resultsProperties: resultsProperties, jobProperties: jobProperties, createMetaProperty: createMetaProperty, fetchMaterials: fetchMaterials, onMaterialAdd: onMaterialAdd, onMaterialRemove: onMaterialRemove, onDestroy: onDestroy, getJobMaterialClient: getJobMaterialClient, onWorkflowSelect: handleWorkflowSelect, onWorkflowUpdate: syncWorkflowWithJob, getRouteQueryTab: getRouteQueryTab, MaterialViewerComponent: MaterialViewerComponent, headerChildren: headerChildren, editable: editable, useGuidedDesigner: useGuidedDesigner })); } function JobLocalReduxContainer(props) { const { job, jobMaterials, metaProperties, workflow: _workflowBootstrap, ...restProps } = props; diff --git a/dist/jobReadiness.d.ts b/dist/jobReadiness.d.ts new file mode 100644 index 0000000..091ef12 --- /dev/null +++ b/dist/jobReadiness.d.ts @@ -0,0 +1,62 @@ +import { type SubmittableJob } from "./jobSubmission"; +/** + * What the job still needs, as a sequence of steps. + * + * The designer's tabs are numbered ("1. Materials", "2. Workflow", "3. Compute") + * but carry no progress: they look identical whether a step is done, half-done + * or untouched, and the actions that complete them live in a dropdown. This is + * the single source of truth for that state — the rail, the context strip, the + * Submit button and the submit preflight all read it, so they cannot disagree + * about whether a job is ready. + * + * A pure function of the job and its inputs. It never touches the entity, and + * in particular never triggers `job.render()`: `Job.jsx` runs that from + * `persistJob()`, and recomputing readiness on a compute keystroke must not drag + * a workflow re-render along with it. + */ +export type ReadinessState = +/** Done — carries a summary of what was chosen. */ +"complete" +/** Started but not usable yet, or holding something invalid. */ + | "attention" +/** Nothing chosen yet. */ + | "empty" +/** Not applicable, or not reachable in this job's status. */ + | "unavailable"; +export interface ReadinessStep { + /** Matches `TAB_NAVIGATION_CONFIG` ids where one exists, so deep links keep working. */ + id: string; + label: string; + state: ReadinessState; + /** One line naming what is chosen, or what to do. Shown under the label. */ + summary: string; +} +export interface JobReadiness { + steps: ReadinessStep[]; + isSubmittable: boolean; + blockingReasons: string[]; + /** True once the job has left draft — creation steps become read-only summaries. */ + isRunOrFinished: boolean; +} +export interface JobReadinessOptions { + job: SubmittableJob & { + name?: string; + status?: string; + isInFinalStatus?: boolean; + getParentJobClient?: () => { + name?: string; + } | null; + compute?: any; + workflow?: any; + }; + materials?: any[]; + /** False for dataset-driven jobs, where a dataset takes the place of materials. */ + isUsingMaterials?: boolean; + datasetConfig?: { + name?: string; + } | null; + /** False for shared or finished jobs: the rail renders view-only. */ + editable?: boolean; +} +export declare function getJobReadiness({ job, materials, isUsingMaterials, datasetConfig, editable, }: JobReadinessOptions): JobReadiness; +//# sourceMappingURL=jobReadiness.d.ts.map \ No newline at end of file diff --git a/dist/jobReadiness.d.ts.map b/dist/jobReadiness.d.ts.map new file mode 100644 index 0000000..0a24761 --- /dev/null +++ b/dist/jobReadiness.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"jobReadiness.d.ts","sourceRoot":"","sources":["../src/jobReadiness.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE;;;;;;;;;;;;;;GAcG;AAEH,MAAM,MAAM,cAAc;AACtB,mDAAmD;AACjD,UAAU;AACZ,gEAAgE;GAC9D,WAAW;AACb,0BAA0B;GACxB,OAAO;AACT,6DAA6D;GAC3D,aAAa,CAAC;AAEpB,MAAM,WAAW,aAAa;IAC1B,wFAAwF;IACxF,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,oFAAoF;IACpF,eAAe,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAChC,GAAG,EAAE,cAAc,GAAG;QAClB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,kBAAkB,CAAC,EAAE,MAAM;YAAE,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAC;QACpD,OAAO,CAAC,EAAE,GAAG,CAAC;QACd,QAAQ,CAAC,EAAE,GAAG,CAAC;KAClB,CAAC;IACF,SAAS,CAAC,EAAE,GAAG,EAAE,CAAC;IAClB,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,aAAa,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACzC,qEAAqE;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AA8FD,wBAAgB,eAAe,CAAC,EAC5B,GAAG,EACH,SAAc,EACd,gBAAuB,EACvB,aAAoB,EACpB,QAAe,GAClB,EAAE,mBAAmB,GAAG,YAAY,CA+CpC"} \ No newline at end of file diff --git a/dist/jobReadiness.js b/dist/jobReadiness.js new file mode 100644 index 0000000..2525344 --- /dev/null +++ b/dist/jobReadiness.js @@ -0,0 +1,125 @@ +import { getSubmitBlockers } from "./jobSubmission"; +const REVIEW_STEP_ID = "review"; +function describeMaterials(materials, parentJobName) { + var _a, _b; + if (parentJobName) + return `From parent job ${parentJobName}`; + if (materials.length === 0) + return "No material selected"; + if (materials.length === 1) { + const [material] = materials; + return (_b = (_a = material === null || material === void 0 ? void 0 : material.formula) !== null && _a !== void 0 ? _a : material === null || material === void 0 ? void 0 : material.name) !== null && _b !== void 0 ? _b : "1 material"; + } + return `${materials.length} materials — runs ${materials.length} times`; +} +function describeWorkflow(workflow) { + var _a, _b; + const subworkflows = (_a = workflow === null || workflow === void 0 ? void 0 : workflow.subworkflows) !== null && _a !== void 0 ? _a : []; + if (!subworkflows.length) + return "No workflow selected"; + const unitCount = subworkflows.reduce((total, subworkflow) => { var _a, _b; return total + ((_b = (_a = subworkflow === null || subworkflow === void 0 ? void 0 : subworkflow.units) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0); }, 0); + const name = (_b = workflow === null || workflow === void 0 ? void 0 : workflow.name) !== null && _b !== void 0 ? _b : `${subworkflows.length} subworkflows`; + return unitCount ? `${name} · ${unitCount} units` : name; +} +function describeCompute(compute) { + var _a; + const clusterName = (_a = compute === null || compute === void 0 ? void 0 : compute.cluster) === null || _a === void 0 ? void 0 : _a.fqdn; + if (!clusterName) + return "Cluster and resources needed"; + const resources = [compute === null || compute === void 0 ? void 0 : compute.nodes, compute === null || compute === void 0 ? void 0 : compute.ppn].every((value) => value) + ? `${compute.nodes}×${compute.ppn}` + : undefined; + return [clusterName, resources, compute === null || compute === void 0 ? void 0 : compute.timeLimit].filter(Boolean).join(" · "); +} +/** + * Steps for creating the job. After submission these stay in the rail but stop + * being things to do — they become the record of what was run. + */ +function getCreationSteps({ job, materials, isUsingMaterials, datasetConfig, parentJobName, }) { + var _a, _b, _c, _d, _e; + const steps = []; + if (isUsingMaterials) { + const hasMaterial = materials.length > 0 || Boolean(parentJobName); + steps.push({ + id: "material", + label: "Material", + state: hasMaterial ? "complete" : "empty", + summary: describeMaterials(materials, parentJobName), + }); + } + else { + steps.push({ + id: "dataset", + label: "Dataset", + state: datasetConfig ? "complete" : "empty", + summary: (_a = datasetConfig === null || datasetConfig === void 0 ? void 0 : datasetConfig.name) !== null && _a !== void 0 ? _a : "No dataset selected", + }); + } + const hasWorkflow = Boolean((_c = (_b = job.workflow) === null || _b === void 0 ? void 0 : _b.subworkflows) === null || _c === void 0 ? void 0 : _c.length); + steps.push({ + id: "workflow", + label: "Workflow", + state: hasWorkflow ? "complete" : "empty", + summary: describeWorkflow(job.workflow), + }); + const hasCompute = Boolean((_e = (_d = job.compute) === null || _d === void 0 ? void 0 : _d.cluster) === null || _e === void 0 ? void 0 : _e.fqdn); + steps.push({ + id: "compute", + label: "Compute", + state: hasCompute ? "complete" : "attention", + summary: describeCompute(job.compute), + }); + return steps; +} +export function getJobReadiness({ job, materials = [], isUsingMaterials = true, datasetConfig = null, editable = true, }) { + const parentJobName = (() => { + var _a, _b, _c; + try { + return (_c = (_b = (_a = job.getParentJobClient) === null || _a === void 0 ? void 0 : _a.call(job)) === null || _b === void 0 ? void 0 : _b.name) !== null && _c !== void 0 ? _c : undefined; + } + catch (_d) { + return undefined; + } + })(); + const blockingReasons = getSubmitBlockers({ job, materials, isUsingMaterials }); + const isDraft = Boolean(job.isInInitialStatus); + const isRunOrFinished = !isDraft; + const steps = getCreationSteps({ + job, + materials, + isUsingMaterials, + datasetConfig, + parentJobName, + }); + if (isRunOrFinished) { + // The job is out of the reader's hands: the creation steps are now a record + // of what ran, and what matters is what it is doing. + steps.push({ + id: "results", + label: job.isInFinalStatus ? "Results" : "Monitor", + state: "complete", + summary: job.isInFinalStatus ? "Outputs and properties" : "Running", + }); + steps.push({ id: "files", label: "Files", state: "complete", summary: "Job directory" }); + return { steps, isSubmittable: false, blockingReasons: [], isRunOrFinished }; + } + const isSubmittable = editable && blockingReasons.length === 0; + steps.push({ + id: REVIEW_STEP_ID, + label: "Review & submit", + // A read-only draft is somebody else's to submit; saying "ready" would + // invite an action this reader cannot take. + state: !editable ? "unavailable" : isSubmittable ? "complete" : "empty", + summary: getReviewSummary({ editable, isSubmittable, blockingReasons }), + }); + return { steps, isSubmittable, blockingReasons, isRunOrFinished }; +} +function getReviewSummary({ editable, isSubmittable, blockingReasons, }) { + if (!editable) + return "View only"; + if (isSubmittable) + return "Ready to submit"; + return blockingReasons.length === 1 + ? "1 step remaining" + : `${blockingReasons.length} steps remaining`; +} diff --git a/dist/standalone/index.js b/dist/standalone/index.js index 7142a63..6811152 100644 --- a/dist/standalone/index.js +++ b/dist/standalone/index.js @@ -72,6 +72,8 @@ function App() { ...m, })); }, []); + // Phase 2 layout, opt-in: the demo is where it gets reviewed before any host flips it on. + const [useGuidedDesigner, setUseGuidedDesigner] = useState(true); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m) => { var _a; return /silicon|^si\b/i.test((_a = m.name) !== null && _a !== void 0 ? _a : ""); }); return idx >= 0 ? idx : 0; @@ -138,7 +140,7 @@ function App() { }) })] })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(ScienceIcon, { fontSize: "small", sx: { color: "secondary.main", flexShrink: 0 } }), _jsxs(FormControl, { size: "small", sx: { minWidth: 240 }, children: [_jsx(InputLabel, { id: "material-select-label", children: "Material" }), _jsx(Select, { labelId: "material-select-label", value: materialIndex, label: "Material", onChange: (e) => setMaterialIndex(Number(e.target.value)), children: allMaterialJsons.map((mat, i) => { var _a, _b; return (_jsx(MenuItem, { value: i, children: (_b = (_a = mat === null || mat === void 0 ? void 0 : mat.name) !== null && _a !== void 0 ? _a : mat === null || mat === void 0 ? void 0 : mat.formula) !== null && _b !== void 0 ? _b : `Material ${i + 1}` }, i)); - }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { + }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setUseGuidedDesigner((isOn) => !isOn), sx: { mr: 1 }, children: useGuidedDesigner ? "Guided layout: on" : "Guided layout: off" }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { borderColor: "rgba(124,77,255,0.5)", color: "primary.main", "&:hover": { @@ -173,7 +175,7 @@ function App() { }, workflowDialogs: { pseudoUploadReduxDialog: [() => { }, () => { }], unitTypeReduxDialog: [() => { }, () => { }], - }, templates: [], resultsProperties: [], jobProperties: [], createMetaProperty: async () => undefined, fetchMaterials: async () => [], loadWorkflowEntityById: async () => undefined, MaterialViewerComponent: StandaloneMaterialViewer }, designerKey) })] })); + }, templates: [], resultsProperties: [], jobProperties: [], createMetaProperty: async () => undefined, fetchMaterials: async () => [], loadWorkflowEntityById: async () => undefined, MaterialViewerComponent: StandaloneMaterialViewer, useGuidedDesigner: useGuidedDesigner }, designerKey) })] })); } const rootElement = document.getElementById("root"); if (!rootElement) diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 19b716f..aad49cd 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -18,9 +18,12 @@ import React from "react"; import { ErrorBoundary } from "react-error-boundary"; import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { getSubmitBlockedReason } from "../jobSubmission"; +import { getJobReadiness } from "../jobReadiness"; import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; import ComputeTab from "./ComputeTab"; +import JobContextStrip from "./JobContextStrip"; +import JobReadinessRail from "./JobReadinessRail"; import DatasetTab from "./DatasetTab"; import FilesTab from "./FilesTab"; import MaterialTab from "./MaterialTab"; @@ -243,6 +246,28 @@ class Job extends mix(React.Component).with( return ""; }; + /** + * Pure derivation - no entity mutation, and in particular no `job.render()`. + * Recomputed per render rather than memoised: it walks a handful of arrays, + * whereas caching it would mean tracking invalidation across the same + * in-place model mutations that already make this component hard to reason + * about. + */ + get jobReadiness() { + return getJobReadiness({ + job: this.state.entity, + materials: this.props.materials ?? [], + isUsingMaterials: this.isUsingMaterialsTab, + datasetConfig: this.props.datasetConfig, + editable: Boolean(this.props.editable), + }); + } + + /** The rail's Review step has no tab of its own; it lands on Compute. */ + onReadinessStepSelect = (stepId) => { + this.setCurrentTab(stepId === "review" ? TAB_NAVIGATION_CONFIG.compute.id : stepId); + }; + get saveStateInputs() { return { hasUnsavedChanges: this.state.hasUnsavedChanges, @@ -321,6 +346,9 @@ class Job extends mix(React.Component).with( }; renderParentJob() { + // The context strip carries the parent as a chip; two of them is one too many. + if (this.props.useGuidedDesigner) return null; + const parentJob = this.state.entity.getParentJobClient?.(); return parentJob ? ( @@ -793,6 +821,15 @@ class Job extends mix(React.Component).with( const activeTabIndex = tabsToRender.findIndex((item) => item.id === this.state.currentTab); + // Phase 2 layout is opt-in per host so the webapp and the demo can flip + // independently; the legacy tab strip stays until parity is verified. + const useGuidedDesigner = Boolean(this.props.useGuidedDesigner); + const readiness = this.jobReadiness; + const parentJobClient = job.getParentJobClient?.(); + const parentJobForStrip = parentJobClient + ? { name: parentJobClient.name, projectSlug: parentJobClient._project?.slug } + : null; + const isDescriptionEditable = this.isDescriptionEditable(job); const isDesignerLoading = isLoading || this.state.isWorkflowLoading; const dropdownProps = this.getDropdownProps(); @@ -866,13 +903,45 @@ class Job extends mix(React.Component).with( {this.renderParentJob()} {this.renderErrors()} {this.renderWarnings()} - - + {useGuidedDesigner ? ( + + ) : null} + {useGuidedDesigner ? null : ( + + )} + + {useGuidedDesigner ? ( + + ) : null}
{this.state.isWorkflowLoading ? ( diff --git a/src/components/JobContextStrip.tsx b/src/components/JobContextStrip.tsx new file mode 100644 index 0000000..e00087a --- /dev/null +++ b/src/components/JobContextStrip.tsx @@ -0,0 +1,104 @@ +import Chip from "@mui/material/Chip"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import type { ReadinessStep } from "../jobReadiness"; + +export interface JobContextStripProps { + steps: ReadinessStep[]; + onSelect: (stepId: string) => void; + /** Parent job, when this job derives from one. Removable while editable. */ + parentJob?: { name?: string; projectSlug?: string } | null; + onParentRemove?: () => void; +} + +/** Steps whose selections are worth carrying on every screen. */ +const CONTEXT_STEP_IDS = new Set(["material", "dataset", "workflow", "compute"]); + +/** + * The job's selections, visible from every step. + * + * Checking which material a job will run on used to mean leaving the step you + * were on — the Compute tab showed no trace of the material or workflow, and + * vice versa. Each chip is also the way back to the step that owns it. + * + * The parent job lives here too. It used to be a dismissable `Alert` sitting + * above the tabs, which is a lot of screen for one fact and put a destructive + * "unset parent" behind an X that reads as "hide this message". + */ +export default function JobContextStrip({ + steps, + onSelect, + parentJob, + onParentRemove, +}: JobContextStripProps) { + const contextSteps = steps.filter((step) => CONTEXT_STEP_IDS.has(step.id)); + if (!contextSteps.length && !parentJob) return null; + + return ( + + {contextSteps.map((step) => { + const needsAttention = step.state !== "complete"; + + return ( + onSelect(step.id)} + label={ + + + {step.label} + + + {step.summary} + + + } + /> + ); + })} + + {parentJob ? ( + + + Parent + + + {parentJob.name} + {parentJob.projectSlug ? ` · ${parentJob.projectSlug}` : ""} + + + } + /> + ) : null} + + ); +} diff --git a/src/components/JobReadinessRail.tsx b/src/components/JobReadinessRail.tsx new file mode 100644 index 0000000..2574249 --- /dev/null +++ b/src/components/JobReadinessRail.tsx @@ -0,0 +1,132 @@ +import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import Box from "@mui/material/Box"; +import ButtonBase from "@mui/material/ButtonBase"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import React from "react"; + +import type { ReadinessState, ReadinessStep } from "../jobReadiness"; + +/** Icon and tone per step state. Never color alone — every state has a glyph. */ +const STATE_PRESENTATION: Record< + ReadinessState, + { iconName: string; color: string; isMuted?: boolean } +> = { + complete: { iconName: "shapes.check", color: "success.main" }, + attention: { iconName: "shapes.info", color: "warning.main" }, + empty: { iconName: "shapes.circle", color: "text.disabled", isMuted: true }, + unavailable: { iconName: "shapes.lock", color: "text.disabled", isMuted: true }, +}; + +export interface JobReadinessRailProps { + steps: ReadinessStep[]; + activeStepId: string; + onSelect: (stepId: string) => void; + /** Rendered under the steps — parent job, import, and other power actions. */ + children?: React.ReactNode; +} + +/** + * The job's lifecycle, with its current state on show. + * + * Replaces the numbered tab strip. Those tabs implied a sequence — "1. Materials, + * 2. Workflow, 3. Compute" — while looking identical whether a step was done or + * untouched, and the actions that complete them lived in a dropdown. Each row + * here carries its own state and a line saying what is chosen. + * + * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, + * and the active one carries `aria-current`. + */ +export default function JobReadinessRail({ + steps, + activeStepId, + onSelect, + children, +}: JobReadinessRailProps) { + const stepRefs = React.useRef>([]); + + const handleKeyDown = (event: React.KeyboardEvent, index: number) => { + const offset = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key]; + if (!offset) return; + + event.preventDefault(); + const nextIndex = (index + offset + steps.length) % steps.length; + stepRefs.current[nextIndex]?.focus(); + }; + + return ( + + {steps.map((step, index) => { + const presentation = STATE_PRESENTATION[step.state]; + const isActive = step.id === activeStepId; + + return ( + { + stepRefs.current[index] = node as HTMLButtonElement | null; + }} + id={`job-step-${step.id}`} + aria-current={isActive ? "step" : undefined} + disabled={step.state === "unavailable"} + onClick={() => onSelect(step.id)} + onKeyDown={(event) => handleKeyDown(event, index)} + sx={{ + justifyContent: "flex-start", + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: 1, + textAlign: "left", + bgcolor: isActive ? "action.selected" : "transparent", + "&:hover": { bgcolor: "action.hover" }, + "&.Mui-focusVisible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }} + > + + + + {step.label} + + + {step.summary} + + + + ); + })} + + {children ? ( + + {children} + + ) : null} + + ); +} diff --git a/src/containers/JobLocalReduxContainer.tsx b/src/containers/JobLocalReduxContainer.tsx index a449ccb..0014f8a 100644 --- a/src/containers/JobLocalReduxContainer.tsx +++ b/src/containers/JobLocalReduxContainer.tsx @@ -104,6 +104,12 @@ interface JobStoreLocalReduxContainerProps { headerChildren?: React.ReactNode; /** Whether the job is editable. */ editable?: boolean; + /** + * Opt into the guided layout (readiness rail + context strip) instead of the + * numbered tab strip. Off by default so hosts flip it when they are ready; + * the legacy path stays until parity is verified. + */ + useGuidedDesigner?: boolean; } type JobStoreLocalReduxContainerInnerProps = JobStoreLocalReduxContainerProps & { @@ -147,6 +153,7 @@ function JobStoreLocalReduxContainer({ MaterialViewerComponent, headerChildren, editable, + useGuidedDesigner, }: JobStoreLocalReduxContainerInnerProps) { const dispatch = useJobDesignerDispatch(); const stateMaterials = useJobDesignerSelector((state: State) => state.materials); @@ -308,6 +315,7 @@ function JobStoreLocalReduxContainer({ MaterialViewerComponent={MaterialViewerComponent} headerChildren={headerChildren} editable={editable} + useGuidedDesigner={useGuidedDesigner} /> ); } diff --git a/src/jobReadiness.ts b/src/jobReadiness.ts new file mode 100644 index 0000000..5878ae8 --- /dev/null +++ b/src/jobReadiness.ts @@ -0,0 +1,225 @@ +import { getSubmitBlockers, type SubmittableJob } from "./jobSubmission"; + +/** + * What the job still needs, as a sequence of steps. + * + * The designer's tabs are numbered ("1. Materials", "2. Workflow", "3. Compute") + * but carry no progress: they look identical whether a step is done, half-done + * or untouched, and the actions that complete them live in a dropdown. This is + * the single source of truth for that state — the rail, the context strip, the + * Submit button and the submit preflight all read it, so they cannot disagree + * about whether a job is ready. + * + * A pure function of the job and its inputs. It never touches the entity, and + * in particular never triggers `job.render()`: `Job.jsx` runs that from + * `persistJob()`, and recomputing readiness on a compute keystroke must not drag + * a workflow re-render along with it. + */ + +export type ReadinessState = + /** Done — carries a summary of what was chosen. */ + | "complete" + /** Started but not usable yet, or holding something invalid. */ + | "attention" + /** Nothing chosen yet. */ + | "empty" + /** Not applicable, or not reachable in this job's status. */ + | "unavailable"; + +export interface ReadinessStep { + /** Matches `TAB_NAVIGATION_CONFIG` ids where one exists, so deep links keep working. */ + id: string; + label: string; + state: ReadinessState; + /** One line naming what is chosen, or what to do. Shown under the label. */ + summary: string; +} + +export interface JobReadiness { + steps: ReadinessStep[]; + isSubmittable: boolean; + blockingReasons: string[]; + /** True once the job has left draft — creation steps become read-only summaries. */ + isRunOrFinished: boolean; +} + +export interface JobReadinessOptions { + job: SubmittableJob & { + name?: string; + status?: string; + isInFinalStatus?: boolean; + getParentJobClient?: () => { name?: string } | null; + compute?: any; + workflow?: any; + }; + materials?: any[]; + /** False for dataset-driven jobs, where a dataset takes the place of materials. */ + isUsingMaterials?: boolean; + datasetConfig?: { name?: string } | null; + /** False for shared or finished jobs: the rail renders view-only. */ + editable?: boolean; +} + +const REVIEW_STEP_ID = "review"; + +function describeMaterials(materials: any[], parentJobName?: string): string { + if (parentJobName) return `From parent job ${parentJobName}`; + if (materials.length === 0) return "No material selected"; + if (materials.length === 1) { + const [material] = materials; + return material?.formula ?? material?.name ?? "1 material"; + } + + return `${materials.length} materials — runs ${materials.length} times`; +} + +function describeWorkflow(workflow: any): string { + const subworkflows = workflow?.subworkflows ?? []; + if (!subworkflows.length) return "No workflow selected"; + + const unitCount = subworkflows.reduce( + (total: number, subworkflow: any) => total + (subworkflow?.units?.length ?? 0), + 0, + ); + const name = workflow?.name ?? `${subworkflows.length} subworkflows`; + + return unitCount ? `${name} · ${unitCount} units` : name; +} + +function describeCompute(compute: any): string { + const clusterName = compute?.cluster?.fqdn; + if (!clusterName) return "Cluster and resources needed"; + + const resources = [compute?.nodes, compute?.ppn].every((value) => value) + ? `${compute.nodes}×${compute.ppn}` + : undefined; + + return [clusterName, resources, compute?.timeLimit].filter(Boolean).join(" · "); +} + +/** + * Steps for creating the job. After submission these stay in the rail but stop + * being things to do — they become the record of what was run. + */ +function getCreationSteps({ + job, + materials, + isUsingMaterials, + datasetConfig, + parentJobName, +}: { + job: JobReadinessOptions["job"]; + materials: any[]; + isUsingMaterials: boolean; + datasetConfig?: { name?: string } | null; + parentJobName?: string; +}): ReadinessStep[] { + const steps: ReadinessStep[] = []; + + if (isUsingMaterials) { + const hasMaterial = materials.length > 0 || Boolean(parentJobName); + steps.push({ + id: "material", + label: "Material", + state: hasMaterial ? "complete" : "empty", + summary: describeMaterials(materials, parentJobName), + }); + } else { + steps.push({ + id: "dataset", + label: "Dataset", + state: datasetConfig ? "complete" : "empty", + summary: datasetConfig?.name ?? "No dataset selected", + }); + } + + const hasWorkflow = Boolean(job.workflow?.subworkflows?.length); + steps.push({ + id: "workflow", + label: "Workflow", + state: hasWorkflow ? "complete" : "empty", + summary: describeWorkflow(job.workflow), + }); + + const hasCompute = Boolean(job.compute?.cluster?.fqdn); + steps.push({ + id: "compute", + label: "Compute", + state: hasCompute ? "complete" : "attention", + summary: describeCompute(job.compute), + }); + + return steps; +} + +export function getJobReadiness({ + job, + materials = [], + isUsingMaterials = true, + datasetConfig = null, + editable = true, +}: JobReadinessOptions): JobReadiness { + const parentJobName = (() => { + try { + return job.getParentJobClient?.()?.name ?? undefined; + } catch { + return undefined; + } + })(); + + const blockingReasons = getSubmitBlockers({ job, materials, isUsingMaterials }); + const isDraft = Boolean(job.isInInitialStatus); + const isRunOrFinished = !isDraft; + + const steps = getCreationSteps({ + job, + materials, + isUsingMaterials, + datasetConfig, + parentJobName, + }); + + if (isRunOrFinished) { + // The job is out of the reader's hands: the creation steps are now a record + // of what ran, and what matters is what it is doing. + steps.push({ + id: "results", + label: job.isInFinalStatus ? "Results" : "Monitor", + state: "complete", + summary: job.isInFinalStatus ? "Outputs and properties" : "Running", + }); + steps.push({ id: "files", label: "Files", state: "complete", summary: "Job directory" }); + + return { steps, isSubmittable: false, blockingReasons: [], isRunOrFinished }; + } + + const isSubmittable = editable && blockingReasons.length === 0; + + steps.push({ + id: REVIEW_STEP_ID, + label: "Review & submit", + // A read-only draft is somebody else's to submit; saying "ready" would + // invite an action this reader cannot take. + state: !editable ? "unavailable" : isSubmittable ? "complete" : "empty", + summary: getReviewSummary({ editable, isSubmittable, blockingReasons }), + }); + + return { steps, isSubmittable, blockingReasons, isRunOrFinished }; +} + +function getReviewSummary({ + editable, + isSubmittable, + blockingReasons, +}: { + editable: boolean; + isSubmittable: boolean; + blockingReasons: string[]; +}): string { + if (!editable) return "View only"; + if (isSubmittable) return "Ready to submit"; + + return blockingReasons.length === 1 + ? "1 step remaining" + : `${blockingReasons.length} steps remaining`; +} diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 90ece95..80a684a 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -85,6 +85,8 @@ function App() { })), [], ); + // Phase 2 layout, opt-in: the demo is where it gets reviewed before any host flips it on. + const [useGuidedDesigner, setUseGuidedDesigner] = useState(true); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m: any) => /silicon|^si\b/i.test(m.name ?? "")); return idx >= 0 ? idx : 0; @@ -220,6 +222,15 @@ function App() { + + @@ -510,6 +558,20 @@ class Job extends mix(React.Component).with( ); } + renderPreflightDialog() { + if (!this.props.useGuidedDesigner) return null; + + return ( + + ); + } + renderTerminateConfirmation() { const job = this.state.entity; @@ -900,6 +962,7 @@ class Job extends mix(React.Component).with( )} {this.renderTerminateConfirmation()} + {this.renderPreflightDialog()} {this.renderParentJob()} {this.renderErrors()} {this.renderWarnings()} @@ -909,6 +972,7 @@ class Job extends mix(React.Component).with( onSelect={this.onReadinessStepSelect} parentJob={parentJobForStrip} onParentRemove={editable ? this.onParentRemove : undefined} + estimateLabel={this.estimateLabel} /> ) : null} {useGuidedDesigner ? null : ( diff --git a/src/components/JobContextStrip.tsx b/src/components/JobContextStrip.tsx index e00087a..b347633 100644 --- a/src/components/JobContextStrip.tsx +++ b/src/components/JobContextStrip.tsx @@ -11,6 +11,12 @@ export interface JobContextStripProps { /** Parent job, when this job derives from one. Removable while editable. */ parentJob?: { name?: string; projectSlug?: string } | null; onParentRemove?: () => void; + /** + * Pre-formatted core-hours and cost from `estimateComputeUsage`. Absent when + * the compute configuration is incomplete or the host published no pricing — + * the chip is then left out rather than shown empty or as zero. + */ + estimateLabel?: string; } /** Steps whose selections are worth carrying on every screen. */ @@ -32,6 +38,7 @@ export default function JobContextStrip({ onSelect, parentJob, onParentRemove, + estimateLabel, }: JobContextStripProps) { const contextSteps = steps.filter((step) => CONTEXT_STEP_IDS.has(step.id)); if (!contextSteps.length && !parentJob) return null; @@ -75,6 +82,29 @@ export default function JobContextStrip({ ); })} + {estimateLabel ? ( + onSelect("compute")} + label={ + + + Estimate + + + {estimateLabel} + + + } + /> + ) : null} + {parentJob ? ( = { + pass: { iconName: "shapes.check", color: "success.main" }, + warn: { iconName: "shapes.info", color: "warning.main" }, + fail: { iconName: "actions.cancel", color: "error.main" }, + skip: { iconName: "shapes.circle", color: "text.disabled" }, +}; + +export interface PreflightDialogProps { + open: boolean; + onClose: () => void; + /** + * What to check, read at the moment the checks run. A getter rather than the + * context itself: the job is mutated in place, so a value captured at render + * time would have the checks judging a stale copy, and a fresh object each + * render would restart them — losing the reader's acknowledgements — every + * time anything else on the page changed. + */ + getContext: () => PreflightContext; + /** Called once the report allows it — the designer's existing submit path. */ + onSubmit: () => void; + /** Sends the reader to the step that fixes a row, closing the dialog. */ + onGoToStep: (stepId: string) => void; +} + +/** One check's outcome, with whatever the reader can do about it. */ +function PreflightRowView({ + row, + isAcknowledged, + isExpanded, + onToggleExpanded, + onAcknowledge, + onFix, +}: { + row: PreflightRow; + isAcknowledged: boolean; + isExpanded: boolean; + onToggleExpanded: () => void; + onAcknowledge: () => void; + onFix: (stepId: string) => void; +}) { + const presentation = STATE_PRESENTATION[row.state]; + const needsAcknowledgement = row.state === "warn" && !isAcknowledged; + + return ( + + + + + + {row.label} + + + {row.detail} + + + + + {row.explanation ? ( + + ) : null} + {needsAcknowledgement ? ( + + ) : null} + {isAcknowledged ? ( + + Acknowledged + + ) : null} + {row.fix ? ( + + ) : null} + + + + {row.explanation ? ( + + + {row.explanation} + + + ) : null} + + ); +} + +/** + * The last look before a job is submitted. + * + * Today Submit fires immediately, and anything the job got wrong — a walltime + * over the queue cap, a template that will not render, a batch that quietly + * costs eight times what the reader expected — surfaces minutes later as a + * failed run. The checks that can be made cheaply are made here instead, while + * the reader is still in a position to change something. + * + * Three outcomes, and they mean different things: a `fail` blocks and offers the + * step that fixes it; a `warn` is the reader's call and needs acknowledging; a + * `skip` is this designer admitting it has no data to judge by, which is not the + * same as approval. + */ +export default function PreflightDialog({ + open, + onClose, + getContext, + onSubmit, + onGoToStep, +}: PreflightDialogProps) { + const [report, setReport] = React.useState(null); + const [isRunning, setIsRunning] = React.useState(false); + const [acknowledged, setAcknowledged] = React.useState([]); + const [expandedRowId, setExpandedRowId] = React.useState(null); + + const getContextRef = React.useRef(getContext); + getContextRef.current = getContext; + + const run = React.useCallback(async () => { + setIsRunning(true); + try { + const nextReport = await runPreflightChecks(getContextRef.current()); + setReport(nextReport); + // Acknowledgements answer a specific report. Keep the ones whose + // warning is still there — re-running after fixing something else + // should not make the reader dismiss the same caveat again — and drop + // the rest. + setAcknowledged((previous) => + previous.filter((id) => nextReport.warnings.includes(id)), + ); + } finally { + setIsRunning(false); + } + }, []); + + React.useEffect(() => { + if (!open) return; + + setAcknowledged([]); + setExpandedRowId(null); + setReport(null); + run(); + }, [open, run]); + + const canSubmit = canSubmitFromReport(report, acknowledged); + + const handleFix = (stepId: string) => { + onClose(); + onGoToStep(stepId); + }; + + return ( + + + + Preflight + + + {isRunning ? "Running checks…" : getReportSummary(report, acknowledged)} + + + + {isRunning ? : null} + + + }> + {(report?.rows ?? []).map((row) => ( + + setExpandedRowId(expandedRowId === row.id ? null : row.id) + } + onAcknowledge={() => setAcknowledged([...acknowledged, row.id])} + onFix={handleFix} + /> + ))} + {!report && !isRunning ? ( + + No checks ran. + + ) : null} + + + + + + + + + + ); +} diff --git a/src/computeEstimate.ts b/src/computeEstimate.ts new file mode 100644 index 0000000..c0b5f05 --- /dev/null +++ b/src/computeEstimate.ts @@ -0,0 +1,166 @@ +/** + * What a compute configuration will consume, and what that costs. + * + * The designer asks for nodes, cores and a walltime and says nothing about what + * they add up to. The same arithmetic is needed in three places — the context + * strip's estimate chip, the submit preflight's budget check, and the compute + * estimate panel — so it lives here once. Three surfaces disagreeing about how + * much a job costs would be worse than none of them saying anything. + * + * Pricing, limits and quota are not in the job document: they are properties of + * the cluster and the account, injected by the host through `setDependencies()` + * (see {@link ClusterMetadata}). Everything here degrades when that metadata is + * absent — core-hours are always computable from the job alone, cost is not, and + * an absent cost is reported as absent rather than as zero. + */ + +/** Per-cluster enrichment the host may inject. Every field is optional. */ +export interface ClusterMetadata { + /** Matches `compute.cluster.fqdn`. */ + fqdn?: string; + /** Human name, when the host has a nicer one than the FQDN. */ + name?: string; + pricePerCoreHour?: number; + /** ISO 4217, e.g. "USD". Only used for display. */ + currency?: string; + limits?: ClusterLimits; + /** Typical wait before the job starts, in minutes. */ + queueWaitMinutes?: number; +} + +export interface ClusterLimits { + maxNodes?: number; + maxPpn?: number; + /** Queue walltime cap, in hours. */ + maxWalltimeHours?: number; +} + +/** Remaining allowance for the account paying for this job. */ +export interface ComputeQuota { + remainingCoreHours?: number; + totalCoreHours?: number; + remainingBalance?: number; + currency?: string; +} + +export interface ComputeEstimate { + /** nodes × cores-per-node × walltime hours. Undefined when a term is missing. */ + coreHours?: number; + /** Only when the cluster carries a price. */ + cost?: number; + currency?: string; + walltimeHours?: number; + nodes?: number; + ppn?: number; +} + +/** The compute half of a job, as the designer holds it. */ +export interface ComputeConfiguration { + cluster?: { fqdn?: string } | null; + nodes?: number; + ppn?: number; + timeLimit?: string; + queue?: string; +} + +/** + * Walltime as hours. Accepts the `HH:MM:SS` and `D-HH:MM:SS` forms the compute + * form produces, and a bare number of hours. Returns undefined for anything it + * cannot read, so callers can tell "no walltime" from "zero hours". + */ +export function parseWalltimeHours(timeLimit?: string | number): number | undefined { + if (typeof timeLimit === "number") return Number.isFinite(timeLimit) ? timeLimit : undefined; + if (!timeLimit) return undefined; + + const [dayPart, clockPart] = timeLimit.includes("-") + ? timeLimit.split("-") + : [undefined, timeLimit]; + + const segments = clockPart.split(":").map((segment) => Number(segment)); + if (!segments.length || segments.some((segment) => !Number.isFinite(segment))) return undefined; + + const [hours = 0, minutes = 0, seconds = 0] = segments; + const days = dayPart === undefined ? 0 : Number(dayPart); + if (!Number.isFinite(days)) return undefined; + + return days * 24 + hours + minutes / 60 + seconds / 3600; +} + +/** Cluster metadata for the cluster this compute points at, if the host gave any. */ +export function findClusterMetadata( + compute?: ComputeConfiguration | null, + clusterMetadata: ClusterMetadata[] = [], +): ClusterMetadata | undefined { + const fqdn = compute?.cluster?.fqdn; + if (!fqdn) return undefined; + + return clusterMetadata.find((cluster) => cluster.fqdn === fqdn); +} + +/** + * Core-hours and cost for one run. Multi-material jobs run once per material — + * pass `runs` so the estimate is what the account will actually be charged + * rather than the per-material figure. + */ +export function estimateComputeUsage( + compute?: ComputeConfiguration | null, + clusterMetadata: ClusterMetadata[] = [], + runs = 1, +): ComputeEstimate { + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const { nodes, ppn } = compute ?? {}; + + const estimate: ComputeEstimate = { walltimeHours, nodes, ppn }; + + if (!nodes || !ppn || walltimeHours === undefined) return estimate; + + estimate.coreHours = nodes * ppn * walltimeHours * Math.max(runs, 1); + + const cluster = findClusterMetadata(compute, clusterMetadata); + if (cluster?.pricePerCoreHour !== undefined) { + estimate.cost = estimate.coreHours * cluster.pricePerCoreHour; + estimate.currency = cluster.currency; + } + + return estimate; +} + +function formatNumber(value: number): string { + // Core-hours below 10 are the interesting ones to see a decimal on; above + // that the fraction is noise next to a queue that rounds to the minute. + return value >= 10 ? String(Math.round(value)) : String(Math.round(value * 10) / 10); +} + +export function formatCoreHours(coreHours?: number): string | undefined { + if (coreHours === undefined) return undefined; + + return `${formatNumber(coreHours)} core·h`; +} + +export function formatCost(cost?: number, currency?: string): string | undefined { + if (cost === undefined) return undefined; + + const amount = cost >= 10 ? cost.toFixed(0) : cost.toFixed(2); + if (!currency) return amount; + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: cost >= 10 ? 0 : 2, + }).format(cost); + } catch { + // An unknown currency code must not take the estimate down with it. + return `${amount} ${currency}`; + } +} + +/** One line for a chip or a summary row. Undefined when nothing is known yet. */ +export function formatEstimate(estimate: ComputeEstimate): string | undefined { + const coreHours = formatCoreHours(estimate.coreHours); + if (!coreHours) return undefined; + + const cost = formatCost(estimate.cost, estimate.currency); + + return cost ? `${coreHours} ≈ ${cost}` : coreHours; +} diff --git a/src/containers/JobLocalReduxContainer.tsx b/src/containers/JobLocalReduxContainer.tsx index 0014f8a..7da5fa1 100644 --- a/src/containers/JobLocalReduxContainer.tsx +++ b/src/containers/JobLocalReduxContainer.tsx @@ -9,6 +9,7 @@ import { applyMiddleware, createStore } from "redux"; import logger from "redux-logger"; import { setMaterials, syncJobWorkflow, updateJob } from "../actions"; +import type { ClusterMetadata, ComputeQuota } from "../computeEstimate"; import { useJobDesignerDeps } from "../JobDesignerContext"; import { createJobDesignerReducer } from "../reducers"; import JobContainer from "./JobContainer"; @@ -110,6 +111,15 @@ interface JobStoreLocalReduxContainerProps { * the legacy path stays until parity is verified. */ useGuidedDesigner?: boolean; + /** + * Per-cluster pricing, limits and queue waits. Not part of the job document — + * the host supplies it here or through `setDependencies({ clusterMetadata })`. + * Without it the estimate and the preflight's limit check report that they + * cannot judge, rather than passing on no evidence. + */ + clusterMetadata?: ClusterMetadata[]; + /** Remaining allowance for the account paying for the job, when the host tracks one. */ + computeQuota?: ComputeQuota | null; } type JobStoreLocalReduxContainerInnerProps = JobStoreLocalReduxContainerProps & { @@ -154,6 +164,8 @@ function JobStoreLocalReduxContainer({ headerChildren, editable, useGuidedDesigner, + clusterMetadata, + computeQuota, }: JobStoreLocalReduxContainerInnerProps) { const dispatch = useJobDesignerDispatch(); const stateMaterials = useJobDesignerSelector((state: State) => state.materials); @@ -316,6 +328,8 @@ function JobStoreLocalReduxContainer({ headerChildren={headerChildren} editable={editable} useGuidedDesigner={useGuidedDesigner} + clusterMetadata={clusterMetadata} + computeQuota={computeQuota} /> ); } diff --git a/src/jobReadiness.ts b/src/jobReadiness.ts index 1387d9b..27cda8c 100644 --- a/src/jobReadiness.ts +++ b/src/jobReadiness.ts @@ -1,3 +1,4 @@ +import { type ClusterMetadata, findClusterMetadata, parseWalltimeHours } from "./computeEstimate"; import { getSubmitBlockers, type SubmittableJob } from "./jobSubmission"; /** @@ -58,6 +59,11 @@ export interface JobReadinessOptions { datasetConfig?: { name?: string } | null; /** False for shared or finished jobs: the rail renders view-only. */ editable?: boolean; + /** + * Per-cluster limits, injected by the host. Absent, the compute step judges + * only whether a cluster was chosen — it does not invent limits to enforce. + */ + clusterMetadata?: ClusterMetadata[]; } const REVIEW_STEP_ID = "review"; @@ -97,6 +103,38 @@ function describeCompute(compute: any): string { return [clusterName, resources, compute?.timeLimit].filter(Boolean).join(" · "); } +/** + * Which published limits this configuration breaks. Empty when it breaks none, + * or when the host published none to check against. + * + * The rail has to know this, not just the preflight: a green Compute step over a + * preflight that refuses to submit is the designer contradicting itself, and the + * reader would only find out at the last click. + */ +function getComputeLimitViolations(compute: any, clusterMetadata: ClusterMetadata[]): string[] { + const limits = findClusterMetadata(compute, clusterMetadata)?.limits; + if (!limits) return []; + + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const violations: string[] = []; + + if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { + violations.push(`over the ${limits.maxNodes}-node limit`); + } + if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { + violations.push(`over ${limits.maxPpn} cores per node`); + } + if ( + limits.maxWalltimeHours !== undefined && + walltimeHours !== undefined && + walltimeHours > limits.maxWalltimeHours + ) { + violations.push(`over the ${limits.maxWalltimeHours} h queue limit`); + } + + return violations; +} + /** * Steps for creating the job. After submission these stay in the rail but stop * being things to do — they become the record of what was run. @@ -107,12 +145,14 @@ function getCreationSteps({ isUsingMaterials, datasetConfig, parentJobName, + clusterMetadata, }: { job: JobReadinessOptions["job"]; materials: any[]; isUsingMaterials: boolean; datasetConfig?: { name?: string } | null; parentJobName?: string; + clusterMetadata: ClusterMetadata[]; }): ReadinessStep[] { const steps: ReadinessStep[] = []; @@ -142,16 +182,38 @@ function getCreationSteps({ }); const hasCompute = Boolean(job.compute?.cluster?.fqdn); + const violations = hasCompute ? getComputeLimitViolations(job.compute, clusterMetadata) : []; steps.push({ id: "compute", label: "Compute", - state: hasCompute ? "complete" : "attention", - summary: describeCompute(job.compute), + state: hasCompute && !violations.length ? "complete" : "attention", + summary: violations.length ? violations.join(" · ") : describeCompute(job.compute), }); return steps; } +/** + * A configuration the cluster will reject is a blocker too, and the reader + * should learn that from the Submit button rather than from the preflight after + * they have decided they are done. Sits with the other compute blocker, ahead of + * "Save the job", which is the one fixed without leaving the header. + */ +function withLimitBlocker( + blockers: string[], + steps: ReadinessStep[], + hasCluster: boolean, +): string[] { + const computeStep = steps.find((step) => step.id === "compute"); + if (!hasCluster || computeStep?.state !== "attention") return blockers; + + const limitBlocker = "Bring compute within the cluster's limits"; + const saveIndex = blockers.indexOf("Save the job"); + if (saveIndex === -1) return [...blockers, limitBlocker]; + + return [...blockers.slice(0, saveIndex), limitBlocker, ...blockers.slice(saveIndex)]; +} + function getReviewState({ editable, isSubmittable, @@ -189,6 +251,7 @@ export function getJobReadiness({ isUsingMaterials = true, datasetConfig = null, editable = true, + clusterMetadata = [], }: JobReadinessOptions): JobReadiness { const parentJobName = (() => { try { @@ -198,7 +261,6 @@ export function getJobReadiness({ } })(); - const blockingReasons = getSubmitBlockers({ job, materials, isUsingMaterials }); const isDraft = Boolean(job.isInInitialStatus); const isRunOrFinished = !isDraft; @@ -208,8 +270,15 @@ export function getJobReadiness({ isUsingMaterials, datasetConfig, parentJobName, + clusterMetadata, }); + const blockingReasons = withLimitBlocker( + getSubmitBlockers({ job, materials, isUsingMaterials }), + steps, + Boolean(job.compute?.cluster?.fqdn), + ); + if (isRunOrFinished) { // The job is out of the reader's hands: the creation steps are now a record // of what ran, and what matters is what it is doing. diff --git a/src/jobSubmission.ts b/src/jobSubmission.ts index f29724d..476962d 100644 --- a/src/jobSubmission.ts +++ b/src/jobSubmission.ts @@ -73,12 +73,21 @@ export function isJobSubmittable(options: SubmitBlockersOptions): boolean { * One line for a disabled Submit button. Names the first thing to fix and how * much else is waiting, rather than listing everything in a tooltip nobody * reads to the end. + * + * Takes the list rather than the job so the button can be driven by + * `getJobReadiness`, which knows about blockers this module cannot see — cluster + * limits come from host-injected metadata, and a Submit button that stayed + * enabled over a preflight that refuses would be the designer contradicting + * itself. */ -export function getSubmitBlockedReason(options: SubmitBlockersOptions): string | null { - const blockers = getSubmitBlockers(options); - +export function formatBlockedReason(blockers: string[]): string | null { if (blockers.length === 0) return null; if (blockers.length === 1) return blockers[0]; return `${blockers[0]} (+${blockers.length - 1} more)`; } + +/** The same line, for callers holding a job rather than a readiness report. */ +export function getSubmitBlockedReason(options: SubmitBlockersOptions): string | null { + return formatBlockedReason(getSubmitBlockers(options)); +} diff --git a/src/preflight/checks.ts b/src/preflight/checks.ts new file mode 100644 index 0000000..24439cc --- /dev/null +++ b/src/preflight/checks.ts @@ -0,0 +1,307 @@ +import { + estimateComputeUsage, + findClusterMetadata, + formatCoreHours, + formatCost, + parseWalltimeHours, +} from "../computeEstimate"; +import { getMaterialSummary } from "../materialSummary"; +import type { PreflightCheck, PreflightRow } from "./types"; + +/** + * The checks that run before a job is submitted, in the order a reader would + * work through them: what it runs on, what it runs, where it runs, what it + * costs. + * + * Each is a pure-ish async function of the preflight context. They are separate + * exports rather than one big function so the host can drop one or slot its own + * between them (see `runPreflightChecks`), and so each can be unit-tested with + * the smallest possible context. + * + * The rule they all follow: a check that cannot be judged returns `skip`, never + * `pass`. A dialog that says "Budget — fine" when it has never seen a price is + * worse than one that says it does not know. + */ + +function capitalise(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function formatHours(hours: number): string { + const rounded = Math.round(hours * 10) / 10; + + return `${rounded} h`; +} + +/** The job runs on something: a material, a set of them, or a dataset. */ +export const checkInputs: PreflightCheck = async ({ + job, + materials = [], + isUsingMaterials = true, +}) => { + if (!isUsingMaterials) { + const hasDataset = Boolean(job.workflow?.isUsingDataset); + + return { + id: "inputs", + label: "Dataset", + state: hasDataset ? "pass" : "fail", + detail: hasDataset ? "Dataset job — materials not required" : "No dataset selected", + fix: hasDataset ? undefined : { label: "Choose a dataset", stepId: "dataset" }, + }; + } + + if (materials.length === 0) { + return { + id: "inputs", + label: "Material", + state: "fail", + detail: "No material selected", + fix: { label: "Choose a material", stepId: "material" }, + }; + } + + if (materials.length > 1) { + return { + id: "inputs", + label: "Material", + state: "pass", + // The multiplier is the thing a reader most often does not expect at + // submit time, so it is the thing the row says. + detail: `${materials.length} materials — the workflow runs ${materials.length} times`, + }; + } + + const summary = getMaterialSummary(materials[0]); + const atoms = summary.atomCount === undefined ? undefined : `${summary.atomCount} atoms`; + + return { + id: "inputs", + label: "Material", + state: "pass", + detail: + [summary.formula ?? summary.name ?? "1 material", atoms].filter(Boolean).join(" · ") || + "1 material", + }; +}; + +/** + * The workflow renders. This is the one check that touches the entity: rendering + * is how template errors surface at all, and it is what submission would do a + * moment later anyway — better to find out here, with a row pointing at the + * workflow, than in a failed run. + */ +export const checkWorkflowRenders: PreflightCheck = async ({ job }) => { + const subworkflows = job.workflow?.subworkflows ?? []; + + if (!subworkflows.length) { + return { + id: "workflow", + label: "Workflow renders", + state: "fail", + detail: "No workflow selected", + fix: { label: "Choose a workflow", stepId: "workflow" }, + }; + } + + const unitCount = subworkflows.reduce( + (total: number, subworkflow: any) => total + (subworkflow?.units?.length ?? 0), + 0, + ); + + if (typeof job.render !== "function") { + return { + id: "workflow", + label: "Workflow renders", + state: "skip", + detail: `${unitCount} units — rendering not available here`, + }; + } + + try { + job.render(); + } catch (error) { + return { + id: "workflow", + label: "Workflow renders", + state: "fail", + detail: "A unit's input template failed to render", + explanation: error instanceof Error ? error.message : String(error), + fix: { label: "Open the workflow", stepId: "workflow" }, + }; + } + + return { + id: "workflow", + label: "Workflow renders", + state: "pass", + detail: `${unitCount} units · all input templates render`, + }; +}; + +/** + * Compute is set, and within the cluster's limits when the host told us what + * they are. Without metadata this can still say whether a cluster and resources + * were chosen — that part needs no host data. + */ +export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata = [] }) => { + const { compute } = job; + const clusterName = compute?.cluster?.fqdn; + + if (!clusterName) { + return { + id: "compute", + label: "Compute within limits", + state: "fail", + detail: "No cluster selected", + fix: { label: "Configure compute", stepId: "compute" }, + }; + } + + const walltimeHours = parseWalltimeHours(compute?.timeLimit); + const resources = `${compute?.nodes ?? "?"}×${compute?.ppn ?? "?"}`; + const limits = findClusterMetadata(compute, clusterMetadata)?.limits; + + if (!limits) { + return { + id: "compute", + label: "Compute within limits", + state: "skip", + detail: `${clusterName} · ${resources} — no published limits to check against`, + }; + } + + const violations: string[] = []; + if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { + violations.push(`${compute.nodes} nodes exceeds the ${limits.maxNodes}-node limit`); + } + if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { + violations.push(`${compute.ppn} cores per node exceeds the limit of ${limits.maxPpn}`); + } + if ( + limits.maxWalltimeHours !== undefined && + walltimeHours !== undefined && + walltimeHours > limits.maxWalltimeHours + ) { + violations.push( + `walltime ${formatHours(walltimeHours)} exceeds the queue limit of ${formatHours( + limits.maxWalltimeHours, + )}`, + ); + } + + if (violations.length) { + return { + id: "compute", + label: "Compute within limits", + state: "fail", + detail: capitalise(violations[0]), + explanation: violations.length > 1 ? violations.map(capitalise).join(". ") : undefined, + fix: { label: "Adjust compute", stepId: "compute" }, + }; + } + + const walltime = walltimeHours === undefined ? undefined : formatHours(walltimeHours); + + return { + id: "compute", + label: "Compute within limits", + state: "pass", + detail: [clusterName, resources, walltime].filter(Boolean).join(" · "), + }; +}; + +/** + * What the run will consume against what is left. Skips rather than passes when + * the host injected no quota — most deployments have none, and a green "Budget" + * row backed by nothing would be a lie the reader has no way to check. + */ +export const checkBudget: PreflightCheck = async ({ + job, + materials = [], + isUsingMaterials = true, + clusterMetadata = [], + quota, +}) => { + const runs = isUsingMaterials ? Math.max(materials.length, 1) : 1; + const estimate = estimateComputeUsage(job.compute, clusterMetadata, runs); + + if (estimate.coreHours === undefined) { + return { + id: "budget", + label: "Budget", + state: "skip", + detail: "Set nodes, cores and a walltime to estimate the cost", + }; + } + + const usage = [ + formatCoreHours(estimate.coreHours), + formatCost(estimate.cost, estimate.currency), + ] + .filter(Boolean) + .join(" ≈ "); + const remaining = quota?.remainingCoreHours; + + if (remaining === undefined) { + return { id: "budget", label: "Budget", state: "skip", detail: usage }; + } + + if (estimate.coreHours > remaining) { + return { + id: "budget", + label: "Budget", + state: "fail", + detail: `${usage} — only ${formatCoreHours(remaining)} left this month`, + fix: { label: "Reduce resources", stepId: "compute" }, + }; + } + + const left = remaining - estimate.coreHours; + // A run that eats most of what is left is worth stopping on, but it is the + // account holder's call, not ours — hence warn, which they can acknowledge. + if (estimate.coreHours > remaining / 2) { + return { + id: "budget", + label: "Budget", + state: "warn", + detail: `${usage} — more than half of the remaining ${formatCoreHours(remaining)}`, + explanation: `${formatCoreHours(left)} would be left after this job.`, + }; + } + + return { + id: "budget", + label: "Budget", + state: "pass", + detail: `${usage} — ${formatCoreHours(left)} would remain`, + }; +}; + +/** + * The job exists server-side. Last, because unlike the others it is fixed with + * the button next to Submit rather than by going to a step. + */ +export const checkSaved: PreflightCheck = async ({ job }) => { + if (job.id) { + return { id: "saved", label: "Saved", state: "pass", detail: "Job is saved" }; + } + + return { + id: "saved", + label: "Saved", + state: "fail", + detail: "The job has never been saved", + fix: { label: "Save the job", stepId: "review" }, + }; +}; + +export const DEFAULT_PREFLIGHT_CHECKS: PreflightCheck[] = [ + checkInputs, + checkWorkflowRenders, + checkComputeLimits, + checkBudget, + checkSaved, +]; + +export type { PreflightRow }; diff --git a/src/preflight/index.ts b/src/preflight/index.ts new file mode 100644 index 0000000..7138545 --- /dev/null +++ b/src/preflight/index.ts @@ -0,0 +1,22 @@ +export { + checkBudget, + checkComputeLimits, + checkInputs, + checkSaved, + checkWorkflowRenders, + DEFAULT_PREFLIGHT_CHECKS, +} from "./checks"; +export { + canSubmitFromReport, + getPreflightChecks, + getReportSummary, + runPreflightChecks, +} from "./runPreflightChecks"; +export type { + PreflightCheck, + PreflightContext, + PreflightFix, + PreflightReport, + PreflightRow, + PreflightState, +} from "./types"; diff --git a/src/preflight/runPreflightChecks.ts b/src/preflight/runPreflightChecks.ts new file mode 100644 index 0000000..e072bcd --- /dev/null +++ b/src/preflight/runPreflightChecks.ts @@ -0,0 +1,101 @@ +import { getInjectedDeps } from "../setDependencies"; +import { DEFAULT_PREFLIGHT_CHECKS } from "./checks"; +import type { PreflightCheck, PreflightContext, PreflightReport, PreflightRow } from "./types"; + +/** + * The default checks plus whatever the host injected. The webapp has checks + * job-designer cannot make on its own — account balance, licence entitlements — + * so it appends them through `setDependencies({ preflightChecks: [...] })`. + */ +export function getPreflightChecks(): PreflightCheck[] { + const injected = (getInjectedDeps() as { preflightChecks?: unknown }).preflightChecks; + const extra = Array.isArray(injected) + ? injected.filter((check): check is PreflightCheck => typeof check === "function") + : []; + + return [...DEFAULT_PREFLIGHT_CHECKS, ...extra]; +} + +async function runOne( + check: PreflightCheck, + context: PreflightContext, +): Promise { + try { + return await check(context); + } catch (error) { + return { + id: `check-error-${check.name || "anonymous"}`, + label: check.name || "Check", + state: "skip", + detail: "This check could not run", + explanation: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Runs the submit checks and reports what it found. + * + * Sequential rather than parallel, deliberately: `checkWorkflowRenders` mutates + * the job entity, so the checks after it must see the rendered state, and the + * rows appear in a fixed reading order either way. + * + * A check that throws does not take the report down with it — the reader gets a + * `skip` row naming the check that broke, and submission is not blocked by our + * own bug. Only a check that deliberately returns `fail` blocks. + */ +export async function runPreflightChecks( + context: PreflightContext, + checks: PreflightCheck[] = getPreflightChecks(), +): Promise { + const rows = await checks.reduce>(async (previous, check) => { + const collected = await previous; + const row = await runOne(check, context); + + return row ? [...collected, row] : collected; + }, Promise.resolve([])); + + return { + rows, + failures: rows.filter((row) => row.state === "fail").map((row) => row.id), + warnings: rows.filter((row) => row.state === "warn").map((row) => row.id), + }; +} + +/** + * Whether Submit may proceed: nothing failed, and every warning has been + * acknowledged. Kept beside the runner so the dialog's button and any + * programmatic caller cannot come to different conclusions. + */ +export function canSubmitFromReport( + report: PreflightReport | null, + acknowledged: ReadonlySet | string[] = [], +): boolean { + if (!report) return false; + if (report.failures.length) return false; + + const acknowledgedIds = Array.isArray(acknowledged) ? new Set(acknowledged) : acknowledged; + + return report.warnings.every((id) => acknowledgedIds.has(id)); +} + +/** One line for the dialog's header: what the report amounts to. */ +export function getReportSummary( + report: PreflightReport | null, + acknowledged: ReadonlySet | string[] = [], +): string { + if (!report) return "Running checks…"; + + const acknowledgedIds = Array.isArray(acknowledged) ? new Set(acknowledged) : acknowledged; + const failures = report.failures.length; + if (failures) return failures === 1 ? "1 problem to fix" : `${failures} problems to fix`; + + const unacknowledged = report.warnings.filter((id) => !acknowledgedIds.has(id)).length; + if (unacknowledged) { + return unacknowledged === 1 + ? "1 warning to acknowledge" + : `${unacknowledged} warnings to acknowledge`; + } + + return "All checks passed"; +} diff --git a/src/preflight/types.ts b/src/preflight/types.ts new file mode 100644 index 0000000..16dc9a7 --- /dev/null +++ b/src/preflight/types.ts @@ -0,0 +1,59 @@ +import type { ClusterMetadata, ComputeQuota } from "../computeEstimate"; +import type { SubmittableJob } from "../jobSubmission"; + +export type PreflightState = + /** Nothing wrong. */ + | "pass" + /** Submittable, but the reader should know. Requires acknowledgement. */ + | "warn" + /** Blocks submission. */ + | "fail" + /** Could not be judged — no data, or the check itself failed. Never blocks. */ + | "skip"; + +/** Where a failing row sends the reader to fix it. */ +export interface PreflightFix { + label: string; + /** A `ReadinessStep.id`, so the rail and the dialog agree on where to land. */ + stepId: string; +} + +export interface PreflightRow { + id: string; + label: string; + state: PreflightState; + /** One line naming what was checked and what was found. */ + detail: string; + /** Longer explanation, shown behind "Details". */ + explanation?: string; + fix?: PreflightFix; +} + +export interface PreflightReport { + rows: PreflightRow[]; + /** Ids of `fail` rows, in the order to fix them. */ + failures: string[]; + /** Ids of `warn` rows, which must be acknowledged before submitting. */ + warnings: string[]; +} + +/** Everything a check may read. Checks must not reach outside it. */ +export interface PreflightContext { + job: SubmittableJob & { + name?: string; + compute?: any; + workflow?: any; + render?: () => void; + }; + materials?: any[]; + isUsingMaterials?: boolean; + clusterMetadata?: ClusterMetadata[]; + quota?: ComputeQuota | null; +} + +/** + * A check returns its row, or null to leave itself out of the report entirely + * (as opposed to `skip`, which says "this was considered and could not be + * judged"). Async so a host-injected check can ask a server about balance. + */ +export type PreflightCheck = (context: PreflightContext) => Promise; diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 80a684a..76ba0f6 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -67,6 +67,115 @@ function downloadJson(data: unknown, filename: string) { URL.revokeObjectURL(url); } +/** + * A queue as `ive`'s QueuesTable expects it: the webapp passes model instances, + * so the table reads `maxAvailableNodect`, `capacity`, `load` and calls + * `getETAClient()`. Plain objects without those crash the queue picker. + */ +function demoQueue({ + name, + displayName, + maxAvailableNodect, + load, + etaMinutes, +}: { + name: string; + displayName: string; + maxAvailableNodect: number; + load: number; + etaMinutes: number; +}) { + return { + name, + displayName, + maxAvailableNodect, + nodeLimit: maxAvailableNodect, + capacity: String(maxAvailableNodect), + load, + getETAClient: () => ({ display: `~${etaMinutes} min` }), + }; +} + +/** + * Clusters for the demo. The webapp fetches these; standalone had an empty list, + * which left the compute step unfillable and the estimate and preflight with + * nothing to judge — so the two states most worth reviewing could never be seen. + */ +const DEMO_CLUSTERS = [ + { + hostname: "cluster-007.exabyte.io", + name: "cluster-007", + displayName: "cluster-007", + isDefault: true, + queues: [ + demoQueue({ + name: "OR", + displayName: "on-demand regular", + maxAvailableNodect: 4, + load: 40, + etaMinutes: 8, + }), + demoQueue({ + name: "OF", + displayName: "on-demand fast", + maxAvailableNodect: 2, + load: 75, + etaMinutes: 2, + }), + demoQueue({ + name: "SR", + displayName: "spot regular", + maxAvailableNodect: 8, + load: 20, + etaMinutes: 45, + }), + ], + }, + { + hostname: "master-production-20160630-cluster-001.exabyte.io", + name: "cluster-001", + displayName: "cluster-001", + queues: [ + demoQueue({ + name: "OR", + displayName: "on-demand regular", + maxAvailableNodect: 2, + load: 60, + etaMinutes: 25, + }), + demoQueue({ + name: "D", + displayName: "debug", + maxAvailableNodect: 1, + load: 10, + etaMinutes: 1, + }), + ], + }, +]; + +/** Pricing, limits and queue waits the host would inject. Not part of the job. */ +const DEMO_CLUSTER_METADATA = [ + { + fqdn: "cluster-007.exabyte.io", + name: "cluster-007", + pricePerCoreHour: 0.08, + currency: "USD", + limits: { maxNodes: 4, maxPpn: 32, maxWalltimeHours: 12 }, + queueWaitMinutes: 8, + }, + { + fqdn: "master-production-20160630-cluster-001.exabyte.io", + name: "cluster-001", + pricePerCoreHour: 0.05, + currency: "USD", + limits: { maxNodes: 2, maxPpn: 16, maxWalltimeHours: 6 }, + queueWaitMinutes: 25, + }, +]; + +const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; + function App() { const allWorkflowJsons = useMemo(() => new WorkflowStandata().getAll() ?? [], []); const [workflowIndex, setWorkflowIndex] = useState(0); @@ -109,7 +218,13 @@ function App() { // pre-submission status makes the header editable (name input + Save button), // matching how the webapp shows a new job - without it the demo header hides // the exact controls the designer is meant to demo. - const newJob = new Job({ name, status: "pre-submission" }); + // + // The `_id` stands in for a job the webapp would have persisted: the demo + // has no server, so `createOrUpdate` is a no-op and the job would never + // acquire one - leaving Submit permanently blocked on "Save the job" and + // the preflight unreachable. The save-state indicator is unaffected; it + // tracks edits, not identity. + const newJob = new Job({ _id: "standalone-job-1", name, status: "pre-submission" }); newJob.setWorkflow(wodeWorkflow); newJob.setMaterial(selectedMaterial); @@ -269,7 +384,7 @@ function App() { } as any } publicAccount={{ entity: { id: "public" } } as any} - clusters={[]} + clusters={DEMO_CLUSTERS} refreshMetaProperties={() => {}} jobDialogs={{ selectMaterialsReduxDialog: { @@ -305,6 +420,8 @@ function App() { loadWorkflowEntityById={async () => undefined} MaterialViewerComponent={StandaloneMaterialViewer as any} useGuidedDesigner={useGuidedDesigner} + clusterMetadata={DEMO_CLUSTER_METADATA} + computeQuota={DEMO_QUOTA} /> diff --git a/tests/computeEstimate.tests.ts b/tests/computeEstimate.tests.ts new file mode 100644 index 0000000..3d420e0 --- /dev/null +++ b/tests/computeEstimate.tests.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + estimateComputeUsage, + findClusterMetadata, + formatCost, + formatEstimate, + parseWalltimeHours, +} from "../src/computeEstimate"; + +const cluster = { + fqdn: "cluster-007.mat3ra.com", + pricePerCoreHour: 0.08, + currency: "USD", +}; + +const compute = { + cluster: { fqdn: cluster.fqdn }, + nodes: 1, + ppn: 16, + timeLimit: "04:00:00", +}; + +describe("parseWalltimeHours", () => { + it("reads the HH:MM:SS the compute form produces", () => { + assert.equal(parseWalltimeHours("04:00:00"), 4); + assert.equal(parseWalltimeHours("00:30:00"), 0.5); + }); + + it("reads the D-HH:MM:SS form used for multi-day walltimes", () => { + assert.equal(parseWalltimeHours("2-12:00:00"), 60); + }); + + it("accepts a bare number of hours", () => { + assert.equal(parseWalltimeHours(12), 12); + }); + + it("returns undefined rather than zero for what it cannot read", () => { + // Zero would be indistinguishable from a free job, and the estimate would + // silently claim the run costs nothing. + assert.equal(parseWalltimeHours(undefined), undefined); + assert.equal(parseWalltimeHours(""), undefined); + assert.equal(parseWalltimeHours("later"), undefined); + }); +}); + +describe("estimateComputeUsage", () => { + it("multiplies nodes, cores and walltime into core-hours", () => { + const estimate = estimateComputeUsage(compute, [cluster]); + assert.equal(estimate.coreHours, 64); + }); + + it("prices the run when the host published a rate", () => { + const estimate = estimateComputeUsage(compute, [cluster]); + assert.equal(estimate.cost, 64 * 0.08); + assert.equal(estimate.currency, "USD"); + }); + + it("omits cost entirely when no pricing was injected", () => { + // Most deployments have none; a zero here would read as "free". + const estimate = estimateComputeUsage(compute, []); + assert.equal(estimate.coreHours, 64); + assert.equal(estimate.cost, undefined); + }); + + it("multiplies by the number of runs for a batch", () => { + const estimate = estimateComputeUsage(compute, [cluster], 3); + assert.equal(estimate.coreHours, 192); + }); + + it("gives no core-hours while the configuration is incomplete", () => { + assert.equal( + estimateComputeUsage({ ...compute, ppn: undefined }, [cluster]).coreHours, + undefined, + ); + assert.equal(estimateComputeUsage(null, [cluster]).coreHours, undefined); + }); + + it("ignores metadata belonging to a different cluster", () => { + const other = { ...cluster, fqdn: "cluster-008.mat3ra.com" }; + assert.equal(estimateComputeUsage(compute, [other]).cost, undefined); + assert.equal(findClusterMetadata(compute, [other, cluster])?.fqdn, cluster.fqdn); + }); +}); + +describe("formatting", () => { + it("states core-hours alone when there is no price", () => { + assert.equal(formatEstimate(estimateComputeUsage(compute, [])), "64 core·h"); + }); + + it("states core-hours and cost together when there is one", () => { + assert.equal(formatEstimate(estimateComputeUsage(compute, [cluster])), "64 core·h ≈ $5.12"); + }); + + it("says nothing at all when nothing is known", () => { + assert.equal(formatEstimate(estimateComputeUsage(null, [])), undefined); + }); + + it("survives a currency code Intl does not know", () => { + assert.equal(formatCost(5.12, "NOTACURRENCY"), "5.12 NOTACURRENCY"); + }); +}); diff --git a/tests/jobReadiness.tests.ts b/tests/jobReadiness.tests.ts index 3f9d594..480c2f9 100644 --- a/tests/jobReadiness.tests.ts +++ b/tests/jobReadiness.tests.ts @@ -119,6 +119,58 @@ describe("getJobReadiness — lifecycle", () => { }); }); +describe("getJobReadiness — cluster limits", () => { + const clusterMetadata = [ + { + fqdn: "cluster-1.mat3ra.com", + limits: { maxNodes: 4, maxPpn: 32, maxWalltimeHours: 12 }, + }, + ]; + + it("flags a walltime over the queue cap on the compute step", () => { + // Otherwise the rail shows Compute complete while the preflight refuses + // to submit, and the reader only finds out at the last click. + const job = { ...readyJob, compute: { ...readyJob.compute, timeLimit: "24:00:00" } }; + const readiness = getJobReadiness({ job, materials: [{}], clusterMetadata }); + assert.equal(stepById(readiness, "compute")?.state, "attention"); + assert.equal(stepById(readiness, "compute")?.summary, "over the 12 h queue limit"); + assert.equal(readiness.isSubmittable, false); + assert.ok(readiness.blockingReasons.includes("Bring compute within the cluster's limits")); + }); + + it("lists every limit it breaks, not just the first", () => { + const job = { + ...readyJob, + compute: { ...readyJob.compute, nodes: 8, ppn: 64, timeLimit: "24:00:00" }, + }; + const readiness = getJobReadiness({ job, materials: [{}], clusterMetadata }); + assert.equal( + stepById(readiness, "compute")?.summary, + "over the 4-node limit · over 32 cores per node · over the 12 h queue limit", + ); + }); + + it("does not invent limits the host never published", () => { + const job = { ...readyJob, compute: { ...readyJob.compute, nodes: 999 } }; + const readiness = getJobReadiness({ job, materials: [{}] }); + assert.equal(stepById(readiness, "compute")?.state, "complete"); + assert.equal(readiness.isSubmittable, true); + }); + + it("keeps the limit blocker ahead of Save, which is fixed without leaving", () => { + const job = { + ...readyJob, + id: undefined, + compute: { ...readyJob.compute, timeLimit: "24:00:00" }, + }; + const readiness = getJobReadiness({ job, materials: [{}], clusterMetadata }); + assert.deepEqual(readiness.blockingReasons, [ + "Bring compute within the cluster's limits", + "Save the job", + ]); + }); +}); + describe("getJobReadiness — robustness", () => { it("survives a parent lookup that throws", () => { const job = { diff --git a/tests/preflight.tests.ts b/tests/preflight.tests.ts new file mode 100644 index 0000000..759c2f0 --- /dev/null +++ b/tests/preflight.tests.ts @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + canSubmitFromReport, + checkBudget, + checkComputeLimits, + checkInputs, + checkWorkflowRenders, + getReportSummary, + type PreflightCheck, + type PreflightContext, + type PreflightReport, + runPreflightChecks, +} from "../src/preflight"; + +const cluster = { + fqdn: "cluster-007.mat3ra.com", + pricePerCoreHour: 0.08, + currency: "USD", + limits: { maxNodes: 4, maxPpn: 32, maxWalltimeHours: 12 }, +}; + +const readyContext = (): PreflightContext => ({ + job: { + id: "job-1", + isInInitialStatus: true, + workflow: { name: "MD", subworkflows: [{ units: [{}, {}] }] }, + compute: { cluster: { fqdn: cluster.fqdn }, nodes: 1, ppn: 16, timeLimit: "04:00:00" }, + render: () => undefined, + }, + materials: [{ formula: "Si2" }], + clusterMetadata: [cluster], +}); + +const rowById = (report: PreflightReport, id: string) => report.rows.find((row) => row.id === id); + +describe("runPreflightChecks", () => { + it("passes every check for a job that is ready", async () => { + const report = await runPreflightChecks(readyContext()); + assert.deepEqual(report.failures, []); + assert.deepEqual(report.warnings, []); + assert.equal(canSubmitFromReport(report), true); + }); + + it("reports each missing piece as its own failure", async () => { + const report = await runPreflightChecks({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }); + assert.deepEqual(report.failures, ["inputs", "workflow", "compute", "saved"]); + assert.equal(canSubmitFromReport(report), false); + }); + + it("sends every failure to the step that fixes it", async () => { + const report = await runPreflightChecks({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }); + assert.deepEqual( + report.rows.filter((row) => row.state === "fail").map((row) => row.fix?.stepId), + ["material", "workflow", "compute", "review"], + ); + }); + + it("runs host-injected checks after the built-in ones", async () => { + const balance: PreflightCheck = async () => ({ + id: "balance", + label: "Balance", + state: "fail", + detail: "No payment method on file", + }); + const report = await runPreflightChecks(readyContext(), [checkInputs, balance]); + assert.deepEqual( + report.rows.map((row) => row.id), + ["inputs", "balance"], + ); + assert.deepEqual(report.failures, ["balance"]); + }); + + it("does not let a broken check block submission", async () => { + // Our own bug must not stand between the reader and their job; the row + // says the check could not run rather than claiming a problem with it. + const broken: PreflightCheck = async () => { + throw new Error("DAO unreachable"); + }; + const report = await runPreflightChecks(readyContext(), [checkInputs, broken]); + assert.deepEqual(report.failures, []); + assert.equal(canSubmitFromReport(report), true); + assert.equal(report.rows[1].state, "skip"); + assert.equal(report.rows[1].explanation, "DAO unreachable"); + }); +}); + +describe("checkInputs", () => { + it("names the material and its size", async () => { + const row = await checkInputs({ + ...readyContext(), + materials: [{ formula: "Si2", basis: { elements: [{}, {}] } }], + }); + assert.equal(row?.state, "pass"); + assert.ok(row?.detail.startsWith("Si2")); + }); + + it("states the multiplier for a batch, because it is what surprises people", async () => { + const row = await checkInputs({ + ...readyContext(), + materials: [{ formula: "Si2" }, { formula: "Ge2" }, { formula: "GaN" }], + }); + assert.equal(row?.detail, "3 materials — the workflow runs 3 times"); + }); + + it("asks for a dataset, not a material, on a dataset job", async () => { + const context = readyContext(); + const row = await checkInputs({ + ...context, + job: { ...context.job, workflow: { subworkflows: [], isUsingDataset: false } }, + isUsingMaterials: false, + materials: [], + }); + assert.equal(row?.state, "fail"); + assert.equal(row?.fix?.stepId, "dataset"); + }); +}); + +describe("checkWorkflowRenders", () => { + it("fails with the template error when rendering throws", async () => { + const context = readyContext(); + const row = await checkWorkflowRenders({ + ...context, + job: { + ...context.job, + render: () => { + throw new Error("KPOINTS: undefined variable 'kgrid'"); + }, + }, + }); + assert.equal(row?.state, "fail"); + assert.equal(row?.explanation, "KPOINTS: undefined variable 'kgrid'"); + assert.equal(row?.fix?.stepId, "workflow"); + }); + + it("skips rather than passes when the host's job cannot render", async () => { + const context = readyContext(); + const row = await checkWorkflowRenders({ + ...context, + job: { ...context.job, render: undefined }, + }); + assert.equal(row?.state, "skip"); + }); +}); + +describe("checkComputeLimits", () => { + it("fails on a walltime over the queue cap and says which cap", async () => { + const context = readyContext(); + const row = await checkComputeLimits({ + ...context, + job: { ...context.job, compute: { ...context.job.compute, timeLimit: "24:00:00" } }, + }); + assert.equal(row?.state, "fail"); + assert.match(row!.detail, /24 h exceeds the queue limit of 12 h/); + assert.equal(row?.fix?.stepId, "compute"); + }); + + it("fails on more nodes than the cluster allows", async () => { + const context = readyContext(); + const row = await checkComputeLimits({ + ...context, + job: { ...context.job, compute: { ...context.job.compute, nodes: 8 } }, + }); + assert.equal(row?.state, "fail"); + assert.match(row!.detail, /8 nodes exceeds the 4-node limit/); + }); + + it("admits it cannot judge when the host published no limits", async () => { + const row = await checkComputeLimits({ ...readyContext(), clusterMetadata: [] }); + assert.equal(row?.state, "skip"); + assert.match(row!.detail, /no published limits/); + }); +}); + +describe("checkBudget", () => { + it("says what is left after the run", async () => { + const row = await checkBudget({ + ...readyContext(), + quota: { remainingCoreHours: 500 }, + }); + assert.equal(row?.state, "pass"); + assert.match(row!.detail, /64 core·h ≈ \$5.12 — 436 core·h would remain/); + }); + + it("fails when the run does not fit in what is left", async () => { + const row = await checkBudget({ ...readyContext(), quota: { remainingCoreHours: 32 } }); + assert.equal(row?.state, "fail"); + assert.equal(row?.fix?.stepId, "compute"); + }); + + it("warns — the account holder's call — when the run eats most of the quota", async () => { + const row = await checkBudget({ ...readyContext(), quota: { remainingCoreHours: 100 } }); + assert.equal(row?.state, "warn"); + }); + + it("counts every material in a batch against the quota", async () => { + const row = await checkBudget({ + ...readyContext(), + materials: [{}, {}, {}], + quota: { remainingCoreHours: 500 }, + }); + assert.match(row!.detail, /192 core·h/); + }); + + it("skips rather than passing when no quota was injected", async () => { + const row = await checkBudget(readyContext()); + assert.equal(row?.state, "skip"); + }); +}); + +describe("acknowledgement", () => { + const warned = (): PreflightReport => ({ + rows: [], + failures: [], + warnings: ["budget"], + }); + + it("holds submission until every warning is acknowledged", () => { + assert.equal(canSubmitFromReport(warned()), false); + assert.equal(canSubmitFromReport(warned(), ["budget"]), true); + }); + + it("never lets an acknowledgement clear a failure", () => { + const report: PreflightReport = { rows: [], failures: ["compute"], warnings: [] }; + assert.equal(canSubmitFromReport(report, ["compute"]), false); + }); + + it("counts problems before warnings in the header line", () => { + assert.equal(getReportSummary(null), "Running checks…"); + assert.equal( + getReportSummary({ rows: [], failures: ["a", "b"], warnings: ["c"] }), + "2 problems to fix", + ); + assert.equal(getReportSummary(warned()), "1 warning to acknowledge"); + assert.equal(getReportSummary(warned(), ["budget"]), "All checks passed"); + }); +}); From b76d1a5966135f1c12bae281fbc85ea98e402390 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:00:16 +0000 Subject: [PATCH 19/33] feat: pass cluster metadata, quota and run count to the compute step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compute redesign lives in @mat3ra/ive; this is the job-designer half of the wiring — the host data ive cannot fetch for itself. The props are inert on ive releases predating phase 2.3, so this lands ahead of that release rather than after it. `runs` is the material count: a batch job runs once per material, and an estimate for one of them would understate the cost by that factor. Also records that src/computeEstimate.ts is a copy of ive's canonical implementation, to be deleted once ive publishes. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/ComputeTab.d.ts | 4 ++++ dist/components/ComputeTab.d.ts.map | 2 +- dist/components/ComputeTab.js | 10 ++++++++-- dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 9 +++++++-- dist/computeEstimate.d.ts | 6 ++++++ dist/computeEstimate.d.ts.map | 2 +- dist/computeEstimate.js | 6 ++++++ src/components/ComputeTab.jsx | 14 ++++++++++++++ src/components/Job.jsx | 10 ++++++++++ src/computeEstimate.ts | 6 ++++++ 11 files changed, 64 insertions(+), 7 deletions(-) diff --git a/dist/components/ComputeTab.d.ts b/dist/components/ComputeTab.d.ts index 0fd7251..bc9d9b8 100644 --- a/dist/components/ComputeTab.d.ts +++ b/dist/components/ComputeTab.d.ts @@ -12,6 +12,10 @@ declare namespace ComputeTab { let currentUser: PropTypes.Validator; let currentAccount: PropTypes.Validator; let clusters: PropTypes.Validator; + let useComputeCards: PropTypes.Requireable; + let clusterMetadata: PropTypes.Requireable; + let computeQuota: PropTypes.Requireable; + let runs: PropTypes.Requireable; } namespace defaultProps { let editable_1: boolean; diff --git a/dist/components/ComputeTab.d.ts.map b/dist/components/ComputeTab.d.ts.map index 3e63787..2428798 100644 --- a/dist/components/ComputeTab.d.ts.map +++ b/dist/components/ComputeTab.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"ComputeTab.d.ts","sourceRoot":"","sources":["../../src/components/ComputeTab.jsx"],"names":[],"mappings":"AAQA,2DAmCC;;;;;;;;;;;;;;;;;;;;;;;kBAvCiB,OAAO;sBADH,YAAY"} \ No newline at end of file +{"version":3,"file":"ComputeTab.d.ts","sourceRoot":"","sources":["../../src/components/ComputeTab.jsx"],"names":[],"mappings":"AAQA,2DA2CC;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA/CiB,OAAO;sBADH,YAAY"} \ No newline at end of file diff --git a/dist/components/ComputeTab.js b/dist/components/ComputeTab.js index bd617a8..6f543ad 100644 --- a/dist/components/ComputeTab.js +++ b/dist/components/ComputeTab.js @@ -6,8 +6,8 @@ import PropTypes from "prop-types"; import React from "react"; import { Compute } from "@mat3ra/ive"; export default function ComputeTab(props) { - const { className, id, role, compute, job, onUpdate, editable, clusters, showHeader, showAdvancedOptions, accountUsers, currentAccount, currentUser, accountUsersIsLoading, } = props; - return (_jsx("div", { className: setClass(className), id: id, role: role, children: _jsx(Compute, { compute: compute, job: job, user: currentUser, account: currentAccount, onUpdate: onUpdate, editable: editable, clusters: clusters, showHeader: showHeader, showAdvancedOptions: showAdvancedOptions, accountUsers: accountUsers, isAccountUsersLoading: accountUsersIsLoading }) })); + const { className, id, role, compute, job, onUpdate, editable, clusters, showHeader, showAdvancedOptions, accountUsers, currentAccount, currentUser, accountUsersIsLoading, useComputeCards, clusterMetadata, computeQuota, runs, } = props; + return (_jsx("div", { className: setClass(className), id: id, role: role, children: _jsx(Compute, { compute: compute, job: job, user: currentUser, account: currentAccount, onUpdate: onUpdate, editable: editable, clusters: clusters, showHeader: showHeader, showAdvancedOptions: showAdvancedOptions, accountUsers: accountUsers, isAccountUsersLoading: accountUsersIsLoading, useComputeCards: useComputeCards, clusterMetadata: clusterMetadata, computeQuota: computeQuota, runs: runs }) })); } ComputeTab.propTypes = { compute: PropTypes.object, @@ -23,6 +23,12 @@ ComputeTab.propTypes = { currentUser: PropTypes.object.isRequired, currentAccount: PropTypes.object.isRequired, clusters: PropTypes.arrayOf(PropTypes.object).isRequired, + /* Phase 2.3 (@mat3ra/ive): cluster cards, resource steppers and the estimate panel. + Ignored by ive releases predating it. */ + useComputeCards: PropTypes.bool, + clusterMetadata: PropTypes.arrayOf(PropTypes.object), + computeQuota: PropTypes.object, + runs: PropTypes.number, }; ComputeTab.defaultProps = { editable: false, diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index 5dc25ea..d30f5bd 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAuIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAuTC;CACJ;kBAnlCiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAuIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAiUC;CACJ;kBA7lCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index 9cdc4cb..b06d2f2 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -629,7 +629,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin closeSelectParentJobDialog(); } render() { - var _a, _b, _c; + var _a, _b, _c, _d; const { editable, isLoading, hideDescription, material, index, length, onUpdateIndex, onMaterialRemove, datasetConfig, allowedWorkflows, onWorkflowSelect, materials, materialsSet, metaProperties, onIsMultiMaterialChanged, onMaterialSwitch, onOutputUpdateRequest, clusters, profile, accountUsers, accountUsersIsLoading, workflowDialogs, publicAccount, project, templates, resultsProperties, jobProperties, createMetaProperty, fetchMaterials, renderGeneration, MaterialViewerComponent, /** Optional children rendered in the right side of the EntityHeader (selectors, export button, etc.). */ headerChildren, } = this.props; @@ -695,7 +695,12 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin : undefined, children: [useGuidedDesigner ? (_jsx(JobReadinessRail, { steps: readiness.steps, activeStepId: this.state.currentTab === TAB_NAVIGATION_CONFIG.compute.id && readiness.isSubmittable ? TAB_NAVIGATION_CONFIG.compute.id - : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) })] })] })); + : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount, + // Phase 2.3 lives in @mat3ra/ive; these are inert + // until a release carrying it is installed. + useComputeCards: useGuidedDesigner, clusterMetadata: this.getPreflightContext().clusterMetadata, computeQuota: this.getPreflightContext().quota, runs: this.isUsingMaterialsTab + ? Math.max((_d = materials === null || materials === void 0 ? void 0 : materials.length) !== null && _d !== void 0 ? _d : 1, 1) + : 1 })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) })] })] })); } } export default Job; diff --git a/dist/computeEstimate.d.ts b/dist/computeEstimate.d.ts index 6dac60d..ad3f741 100644 --- a/dist/computeEstimate.d.ts +++ b/dist/computeEstimate.d.ts @@ -7,6 +7,12 @@ * estimate panel — so it lives here once. Three surfaces disagreeing about how * much a job costs would be worse than none of them saying anything. * + * TODO(SOF-8023): this is a copy. The canonical implementation is + * `@mat3ra/ive`'s `utils/computeEstimate`, beside the form that holds the live + * values; it is duplicated here only because the ive release carrying it has not + * shipped yet. Delete this module and import from ive once it has — the two are + * identical today and must not be allowed to drift. + * * Pricing, limits and quota are not in the job document: they are properties of * the cluster and the account, injected by the host through `setDependencies()` * (see {@link ClusterMetadata}). Everything here degrades when that metadata is diff --git a/dist/computeEstimate.d.ts.map b/dist/computeEstimate.d.ts.map index 8f9f57a..08b1094 100644 --- a/dist/computeEstimate.d.ts.map +++ b/dist/computeEstimate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"computeEstimate.d.ts","sourceRoot":"","sources":["../src/computeEstimate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC5B,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,sDAAsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC5B,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,2DAA2D;AAC3D,MAAM,WAAW,oBAAoB;IACjC,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAgBlF;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAC/B,OAAO,CAAC,EAAE,oBAAoB,GAAG,IAAI,EACrC,eAAe,GAAE,eAAe,EAAO,GACxC,eAAe,GAAG,SAAS,CAK7B;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,OAAO,CAAC,EAAE,oBAAoB,GAAG,IAAI,EACrC,eAAe,GAAE,eAAe,EAAO,EACvC,IAAI,SAAI,GACT,eAAe,CAiBjB;AAQD,wBAAgB,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAItE;AAED,wBAAgB,UAAU,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgB/E;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAO5E"} \ No newline at end of file +{"version":3,"file":"computeEstimate.d.ts","sourceRoot":"","sources":["../src/computeEstimate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC5B,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,sDAAsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oCAAoC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC5B,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,2DAA2D;AAC3D,MAAM,WAAW,oBAAoB;IACjC,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAgBlF;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAC/B,OAAO,CAAC,EAAE,oBAAoB,GAAG,IAAI,EACrC,eAAe,GAAE,eAAe,EAAO,GACxC,eAAe,GAAG,SAAS,CAK7B;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,OAAO,CAAC,EAAE,oBAAoB,GAAG,IAAI,EACrC,eAAe,GAAE,eAAe,EAAO,EACvC,IAAI,SAAI,GACT,eAAe,CAiBjB;AAQD,wBAAgB,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAItE;AAED,wBAAgB,UAAU,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgB/E;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAO5E"} \ No newline at end of file diff --git a/dist/computeEstimate.js b/dist/computeEstimate.js index 7e9b663..c9d4948 100644 --- a/dist/computeEstimate.js +++ b/dist/computeEstimate.js @@ -7,6 +7,12 @@ * estimate panel — so it lives here once. Three surfaces disagreeing about how * much a job costs would be worse than none of them saying anything. * + * TODO(SOF-8023): this is a copy. The canonical implementation is + * `@mat3ra/ive`'s `utils/computeEstimate`, beside the form that holds the live + * values; it is duplicated here only because the ive release carrying it has not + * shipped yet. Delete this module and import from ive once it has — the two are + * identical today and must not be allowed to drift. + * * Pricing, limits and quota are not in the job document: they are properties of * the cluster and the account, injected by the host through `setDependencies()` * (see {@link ClusterMetadata}). Everything here degrades when that metadata is diff --git a/src/components/ComputeTab.jsx b/src/components/ComputeTab.jsx index 90374d6..484eeaa 100644 --- a/src/components/ComputeTab.jsx +++ b/src/components/ComputeTab.jsx @@ -22,6 +22,10 @@ export default function ComputeTab(props) { currentAccount, currentUser, accountUsersIsLoading, + useComputeCards, + clusterMetadata, + computeQuota, + runs, } = props; return ( @@ -38,6 +42,10 @@ export default function ComputeTab(props) { showAdvancedOptions={showAdvancedOptions} accountUsers={accountUsers} isAccountUsersLoading={accountUsersIsLoading} + useComputeCards={useComputeCards} + clusterMetadata={clusterMetadata} + computeQuota={computeQuota} + runs={runs} /> ); @@ -58,6 +66,12 @@ ComputeTab.propTypes = { currentUser: PropTypes.object.isRequired, currentAccount: PropTypes.object.isRequired, clusters: PropTypes.arrayOf(PropTypes.object).isRequired, + /* Phase 2.3 (@mat3ra/ive): cluster cards, resource steppers and the estimate panel. + Ignored by ive releases predating it. */ + useComputeCards: PropTypes.bool, + clusterMetadata: PropTypes.arrayOf(PropTypes.object), + computeQuota: PropTypes.object, + runs: PropTypes.number, }; ComputeTab.defaultProps = { diff --git a/src/components/Job.jsx b/src/components/Job.jsx index a5c508c..6882ebb 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -1087,6 +1087,16 @@ class Job extends mix(React.Component).with( accountUsersIsLoading={accountUsersIsLoading} currentUser={currentUser} currentAccount={currentAccount} + // Phase 2.3 lives in @mat3ra/ive; these are inert + // until a release carrying it is installed. + useComputeCards={useGuidedDesigner} + clusterMetadata={this.getPreflightContext().clusterMetadata} + computeQuota={this.getPreflightContext().quota} + runs={ + this.isUsingMaterialsTab + ? Math.max(materials?.length ?? 1, 1) + : 1 + } /> )} {isCurrentTabResults && ( diff --git a/src/computeEstimate.ts b/src/computeEstimate.ts index c0b5f05..2ea2f0b 100644 --- a/src/computeEstimate.ts +++ b/src/computeEstimate.ts @@ -7,6 +7,12 @@ * estimate panel — so it lives here once. Three surfaces disagreeing about how * much a job costs would be worse than none of them saying anything. * + * TODO(SOF-8023): this is a copy. The canonical implementation is + * `@mat3ra/ive`'s `utils/computeEstimate`, beside the form that holds the live + * values; it is duplicated here only because the ive release carrying it has not + * shipped yet. Delete this module and import from ive once it has — the two are + * identical today and must not be allowed to drift. + * * Pricing, limits and quota are not in the job document: they are properties of * the cluster and the account, injected by the host through `setDependencies()` * (see {@link ClusterMetadata}). Everything here degrades when that metadata is From 63da4cb817f96373335f0647b8faed1ce25fab61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:03:04 +0000 Subject: [PATCH 20/33] docs: move phase 2 to review and record what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 is built across five repos, so its document moves out of upcoming/ per the folder's own convention, with a Status section saying what actually landed and where it diverged from the plan. The divergences worth reading: the estimator became ive's public arithmetic rather than a panel-local helper, with a temporary copy in job-designer and a TODO to delete it; 2.3 is additive and opt-in rather than a rewrite of the compute form; the preflight grew a fourth outcome (skip) for checks the host published no data to judge; the readiness selector had to learn about cluster limits so the rail, the Submit button and the preflight could not contradict each other; and jode needed no change at all, which takes a package out of the release train. Still open is recorded too: queue-derived limits, the same-as-last-job preset, the cove → ive → job-designer publish order, and the fact that nothing in the webapp publishes pricing or quota yet. Open question 1 is downgraded from blocking to "what does the webapp inject" — the UI now degrades honestly without an answer. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...026-08-16-Job-Designer-Phase-1-De-noise.md | 5 + ...17-Job-Designer-Phase-2-Guided-Designer.md | 230 ++++++++++++++++++ ...08-16-Job-Designer-Guided-Designer-Plan.md | 147 ++--------- 3 files changed, 249 insertions(+), 133 deletions(-) create mode 100644 plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md diff --git a/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md index 6340e16..e584761 100644 --- a/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md +++ b/plan/review/2026-08-16-Job-Designer-Phase-1-De-noise.md @@ -55,6 +55,11 @@ disappeared when unavailable to a header button that names what is missing. - Nothing from Phase 1 is unimplemented. The remaining work is review and the release order recorded in the parent plan (cove → wove → ive / workflow-designer → job-designer). +- Phase 2 has since been built on the same branches; see + [`2026-08-17-Job-Designer-Phase-2-Guided-Designer.md`](2026-08-17-Job-Designer-Phase-2-Guided-Designer.md). + It adds four cove primitives and new ive components to the same release train, so the + order above now matters more, not less: ive's compute redesign will not render against a + cove that predates them. ## Phase 1 — De-noise (proposals B4, D1, D2, A3) diff --git a/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md new file mode 100644 index 0000000..c4e291c --- /dev/null +++ b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md @@ -0,0 +1,230 @@ +# Job Designer Phase 2 — Guided Designer + +- **Ticket:** [SOF-8023](https://mat3ra.atlassian.net/browse/SOF-8023) — Job Designer UX update. +- **Parent:** [../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md](../upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md) + (overview, current state, cove design-language audit, cross-cutting concerns). +- **Status:** built and on branches — every item below is implemented and in review. + Nothing merged yet, so this document sits in `review/` rather than `implemented/`. +- **Created:** 2026-08-16 · **Updated:** 2026-08-17 + +## Status — what shipped + +| Item | Where | PR | +|------|-------|----| +| 2.1 Readiness rail | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.2 Context strip | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.3 Compute redesign | `@mat3ra/cove` + `@mat3ra/ive` + job-designer | [cove#97](https://github.com/mat3ra/cove/pull/97), [ive#6](https://github.com/mat3ra/ive/pull/6), [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.4 Preflight at submit | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.5 Materials tray and metadata | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | +| 2.6 Save-state honesty | job-designer | [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) | + +The whole phase is behind one opt-in flag, `useGuidedDesigner` (job-designer) / +`useComputeCards` (ive), defaulted **on** in the standalone demos and **off** for hosts. A +host on the old layout sees no change at all; the demo is where the new one gets reviewed. + +Measured in the standalone demos: a draft with nothing configured reports **4 steps +remaining** and a Submit that names the first of them; filling compute moves it to **Ready +to submit** with an estimate chip reading `64 core·h ≈ $5.12`; the preflight on that job +returns **five passes**; raising the walltime to 24 h turns the Compute step to *over the +12 h queue limit*, disables Submit, and puts the reason in its tooltip; a within-limits +4 × 32 × 12 h job passes the limits check and **fails the budget** at 1536 of 500 core·h, +with "Reduce resources" landing on the Compute step; a warning holds Submit until +acknowledged, then releases it. + +### Divergences from the plan as written + +- **2.3's estimator did not land in ive as a panel-local helper; it is the package's public + arithmetic, and job-designer carries a temporary copy.** The plan put + `estimateComputeCost` "beside the panel". It is instead `@mat3ra/ive`'s + `utils/computeEstimate`, exported, because three surfaces need the same answer — ive's + estimate panel, job-designer's context-strip chip, and the preflight's budget check — + and three implementations of "what does this job cost" would be worse than none. Until + an ive release ships it, job-designer holds an identical copy at `src/computeEstimate.ts` + carrying a `TODO(SOF-8023)` to delete it and import from ive. +- **2.3 is opt-in and additive rather than a rewrite of the compute form.** The cluster, + queue, nodes, cores and walltime fields move to a new surface above the schema form and + are *hidden* in it — not removed — so the ESSE schema still validates them and writes go + through the same `handleFormUpdate` path as a keystroke. Espresso's advanced options are + untouched. The group left holding only two documentation links is retitled + *Documentation*, since calling it "Cluster" would send readers looking for a picker that + moved. +- **2.4 is gated on the same flag as the layout.** The plan did not say. Submit opening a + dialog changes what one click does, so hosts still on the numbered tabs keep today's + one-click submit rather than silently gaining a second step. +- **The preflight distinguishes four outcomes, not three.** Alongside pass / warn / fail + there is **skip**: a check that could not be judged, because the host published no + pricing, no limits or no quota. Most deployments inject none of that, and a green + "Budget" row backed by nothing would be a lie the reader has no way to check. +- **The readiness selector had to learn about cluster limits, and the Submit button had to + stop calling `getSubmitBlockers` itself.** Without it the rail showed Compute *complete* + over a preflight that refused to submit — the exact contradiction the selector exists to + prevent. `getSubmitBlockedReason` was split so the button can be driven from the + readiness report instead of recomputing its own. +- **2.1's `TAB_NAVIGATION_CONFIG` change was not needed.** The plan proposed growing + per-tab step metadata in `@mat3ra/jode`. The rail derives order and labels from the + readiness selector, and reuses the existing tab ids for deep links, so jode is untouched + — one fewer package in the release train. +- **2.2's estimate chip arrived with 2.4, not with the strip.** It needs the estimator, + which is 2.3's; the strip shipped first without it and gained the chip once the + arithmetic existed. +- **The standalone demo needed real data before any of this could be reviewed.** It passed + `clusters={[]}`, so the compute step could not be filled at all, and the job had no `_id`, + so Submit was permanently blocked on "Save the job". Both are fixed in the demo with a + comment saying why. The demo's queue objects also needed `getETAClient()` — ive's queue + table calls it, and a plain object crashes the picker. +- **4 → 5 checks.** The plan listed material, workflow renders, compute limits, and cost vs + quota. A fifth — *Saved* — was added, because "the job has never been saved" was + otherwise reported only by a disabled button. + +### Still open + +- **Queue-derived limits.** Limits come from `clusterMetadata` per cluster; real queues + differ within a cluster (`nodeLimit`, `maxPPN` per queue). The shape supports it — + `ClusterLimits` would move under the queue — but no host publishes it yet. +- **Same-as-last-job preset.** The plan listed it alongside Debug / Standard / Production. + It needs the reader's previous job, which is host data job-designer does not hold; it + wants an injected `getLastComputeConfiguration()` and is not built. +- **Release ordering.** ive's new components import cove's new primitives, and + job-designer's compute wiring is inert until ive ships. Publish order is cove → ive → + job-designer. +- **Webapp-side data.** `clusterMetadata` and `computeQuota` are injected props with demo + values only; nothing in the webapp publishes pricing, limits or quota yet. Until it does, + the estimate shows core-hours alone and the limit and budget checks report *skip*. + +## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) + +The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, +`03-preflight-submit.html`. + +### 2.1 Readiness rail replaces numbered tabs (A1) — job-designer, `@mat3ra/jode` + +- New `JobReadinessRail` component (job-designer) renders lifecycle steps — Material, Workflow, + Compute, Review & Submit (+ Dataset when `workflow.isUsingDataset`; Results, Files after + submission) — each with state (complete / needs attention / empty) and a one-line selection + summary. It replaces `TabsMenu` as the designer's navigation; `currentTab` state machine in + `Job.jsx` stays, only the navigation surface changes. +- Step state derives from a new pure selector module (proposed `src/jobReadiness.ts`): + `getJobReadiness(job, materials) → { steps: [{ id, state, summary }], isSubmittable, + blockingReasons }`. Unit-test this module heavily; it also drives the Submit button and + preflight. It must cover all creation shapes: material jobs, dataset jobs + (`workflow.isUsingDataset`), multi-material sets (`materialsSet` / `isMultiMaterial`), and + parent-derived jobs (parent supplies the material — the Material step reads "from parent + job", not "missing"). +- **The rail spans the whole lifecycle, not just creation.** After submission the creation + steps collapse into read-only summaries and Monitor (later Results, Files) become the active + steps — this replaces today's `defaultTab` status-jumping logic. With `editable={false}` + (shared/public jobs) the rail renders view-only: no "Change" affordances, no Submit. +- `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) grows optional per-tab step metadata (order, label) + so webapp and standalone agree on the sequence. +- The three "Select …" dialogs stay as they are, but open from "Change" affordances on their + steps. +- Layout: rail left (fixed ~260 px, collapses to a horizontal stepper under 760 px — the + compact variant can extend cove's existing `StyledStepper`), content right. Step state + renders with cove `StatusChip` colors from the job-status mapping (1.5). Keep the DOM of tab + panels unchanged where possible so Cypress selectors survive. +- Acceptance: a new user can create and submit a job without opening any dropdown; deep links + via `getRouteQueryTab` still land on the right step; a finished job opens on Monitor/Results + with creation steps summarized; the rail is keyboard-navigable (arrow keys between steps, + visible focus, `aria-current` on the active step). Size: L. +- **Built** — `src/jobReadiness.ts` (16 unit tests) and `src/components/JobReadinessRail.tsx`. + `@mat3ra/jode` was left alone: the rail takes its order and labels from the selector and + reuses the existing tab ids, so deep links keep working without new schema. The selector + also reads host-published cluster limits, so the Compute step says *over the 12 h queue + limit* rather than showing a green tick over a preflight that would refuse. + +### 2.2 Context strip (A2) — job-designer + +- New `JobContextStrip` under the header: chips for material (formula · source), workflow + (name · subworkflow/unit counts), compute (cluster · nodes×cores · walltime), estimate + (core-hours ≈ cost). Chips navigate to their step; incomplete chips render in the attention + style. Data comes from the same `getJobReadiness` selector plus the estimate helper (2.3). +- The parent job moves here too: today it renders as a dismissable `Alert` above the tabs + (`Job.jsx#renderParentJob`); it becomes a context chip (parent name · project) with the + remove affordance in its popover — same `setParent` / `unsetParent` model calls (the flow + fixed in SOF-7962). +- Acceptance: on every step, the other selections stay visible; clicking a chip switches step; + a parent-derived job shows the parent chip and no orphaned Alert. Size: S–M. +- **Built** — `src/components/JobContextStrip.tsx`. The estimate chip arrived later, with + 2.3's estimator; the strip shipped first without it rather than showing a placeholder. + +### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` + +- Cluster picker becomes selectable cards — cove `SelectableCard` with a `StatusChip` queue + badge — fed by the existing `clusters` prop; job-designer passes an optional + `clusterMetadata` enrichment (pricing, limits, queue wait) injected by the webapp through + `setDependencies()` — standalone falls back to static demo data. Nodes / cores / walltime + become cove `NumericStepperInput`s with min/max from `clusterMetadata`. +- New `ComputeEstimatePanel` built from cove `MetricTile`s and a `SegmentedMeter` for quota: + core-hours = nodes × cores × walltime, price, queue ETA, monthly quota. Pure function + `estimateComputeCost(computeConfiguration, clusterMetadata)` lives beside the panel and is + unit-tested. +- Presets row (Debug / Standard / Production / same-as-last-job) writes through the normal + `onUpdate(compute)` path so undo/save semantics are untouched. +- Validation limits (max nodes, cores per node, queue walltime caps) come from + `clusterMetadata`; violations render inline (not red-on-first-paint — 1.1's touched logic). +- The advanced-options section (`showAdvancedComputeOptions`, gated today by the applications' + `hasAdvancedComputeOptions` in `Job.jsx`) is preserved as a collapsed "Advanced" group below + the cards — redesign must not drop the espresso-class options. +- Acceptance: changing any field updates the estimate synchronously; exceeding a limit flags + the field and the estimate panel; presets fill the form in one click; advanced options remain + reachable; webapp data path and standalone fallback both render. Size: L. +- **Built** — four new cove primitives (`SelectableCard`, `MetricTile`, `SegmentedMeter`, + `NumericStepperInput`, with their bound arithmetic and meter geometry unit-tested), then + `ClusterCards`, `ComputeResources` and `ComputeEstimatePanel` in ive behind + `useComputeCards`. Presets are clamped to the cluster's limits, so the button that exists + to avoid an invalid configuration cannot produce one. Open question 1 is still open — + nothing publishes pricing or quota — so the panel degrades per tile rather than waiting on + it. + +### 2.4 Preflight at submit (C1) — job-designer (+ webapp data) + +- New `PreflightDialog` opened by Submit: runs ordered checks — material set, workflow renders + (`job.render()` succeeds / template errors empty), compute within `clusterMetadata` limits, + estimated cost vs. remaining quota. Each check row: pass / warn / fail; fails deep-link to + the owning step; warns require acknowledge. Submit proceeds only with zero fails and all + warns acknowledged, then calls the existing `onSubmit` prop. +- Check implementations live in `src/preflight/` as pure async functions + `runPreflightChecks(job, materials, clusterMetadata, quota) → PreflightReport`, injectable so + the webapp can add checks (e.g. balance) via `setDependencies()`. +- Acceptance: submitting an incomplete job is impossible through the UI; every fail row's + action lands on the field that fixes it; checks are unit-tested including the warn/ack flow. + Size: M–L. +- **Built** — `src/preflight/` (five checks, a runner, 20 unit tests) and + `src/components/PreflightDialog.tsx`. Hosts append their own checks through + `setDependencies({ preflightChecks })`. A check that throws yields a *skip* row rather than + blocking submission: our own bug must not stand between a reader and their job. + +### 2.5 Materials tray and metadata (E1, E2) — job-designer (+ viewer package) + +- `MaterialTab` gains a chips tray above the viewer (add / remove / switch — reusing + `onUpdateIndex`, `onMaterialRemove`, `openAddMaterialsDialog`) and the explicit copy + "N materials → the workflow runs N times". Multi-material switching stops hiding inside the + Workflow tab (the workflow pane's switcher stays for parity but the tray is the primary + affordance). +- Metadata side panel (formula, lattice, space group, atom count, source id) rendered from the + `Material` model next to the injected viewer; the viewer component API is unchanged. +- Acceptance: adding a second material updates the tray, the batch copy, and the context strip; + removing the active material selects a sane neighbor. Size: M. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Divergence: + the metadata is read through a defensive `getMaterialSummary()` that omits any field it + cannot read, because `MaterialTab` already renders a fallback for hosts passing a plain + config and made's model getters throw on partial data. Space group is shown only when the + model actually carries a `symmetry` derived property — standata materials generally do not, + so the row is usually absent rather than guessed from the name. + +### 2.6 Save-state honesty (new; relates to UX-498) + +- The header states the truth about persistence: "Saved" only after the entity actually + persisted through `onSave` / the `shouldPersistJobOnUpdate` pipeline, "Unsaved changes" + otherwise, and a leave-guard (browser `beforeunload` + router guard injected via the seam) + when a dirty draft is about to be abandoned. The mockups' "All changes saved" copy is the + target state; showing it without it being true would be worse than today. +- Explicit non-goal here: no new autosave backend — this item only surfaces existing state + honestly. If product wants real autosave (UX-498 direction), that is a separate ticket. +- Acceptance: editing any field flips the indicator to dirty; Save flips it back; closing the + tab with a dirty draft warns; the indicator never claims "Saved" while in-memory state + differs from the persisted entity. Size: S–M. +- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Dirty state is + marked in the mutating handlers rather than in `persistJob()`, which also runs on mount and + on entering the Workflow tab; and both save paths were routed through a single `saveJob()` + so the flag cannot be cleared by one header and missed by the other. diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index 27fe0f1..195ee87 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -13,8 +13,7 @@ six portion documents in that repo's `plan/`). The two overlap in three places, documents as authoritative for those: its **portion 2** covers the cove design language this plan's §"Design language" opens (cove#97 implements the semantic and dark-palette half); its **portion 1 items 1.1/1.5** are this plan's item 1.3; and its **portion 3** covers the -`@mat3ra/ive` compute form that phase 2.3 below rebuilds. Coordinate before starting any of -those. +`@mat3ra/ive` compute form that phase 2.3 rebuilds. Coordinate before starting any of those. ## Summary @@ -144,134 +143,11 @@ are recorded in Phases are split across documents so each can move through `plan/` independently, as the folder's own convention asks — this overview moves last. +## Phase 2 — Guided designer (moved) -## Phase 2 — Guided designer (proposals A1, A2, B1–B3, C1, E1, E2) - -> **Progress (2026-08-16):** 2.5 and 2.6 are **built and in review** on -> [job-designer#19](https://github.com/mat3ra/job-designer/pull/19) — they are job-designer-local -> and needed no new cove primitives, so they were taken out of order. 2.1–2.4 remain gated: -> the readiness rail and context strip consume `StatusChip` from -> [cove#97](https://github.com/mat3ra/cove/pull/97), and the estimator half of 2.3 is blocked on -> open question 1 (where pricing and quota live). The limits/validation half of 2.3 can proceed -> without that answer. - -The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, -`03-preflight-submit.html`. - -### 2.1 Readiness rail replaces numbered tabs (A1) — job-designer, `@mat3ra/jode` - -- New `JobReadinessRail` component (job-designer) renders lifecycle steps — Material, Workflow, - Compute, Review & Submit (+ Dataset when `workflow.isUsingDataset`; Results, Files after - submission) — each with state (complete / needs attention / empty) and a one-line selection - summary. It replaces `TabsMenu` as the designer's navigation; `currentTab` state machine in - `Job.jsx` stays, only the navigation surface changes. -- Step state derives from a new pure selector module (proposed `src/jobReadiness.ts`): - `getJobReadiness(job, materials) → { steps: [{ id, state, summary }], isSubmittable, - blockingReasons }`. Unit-test this module heavily; it also drives the Submit button and - preflight. It must cover all creation shapes: material jobs, dataset jobs - (`workflow.isUsingDataset`), multi-material sets (`materialsSet` / `isMultiMaterial`), and - parent-derived jobs (parent supplies the material — the Material step reads "from parent - job", not "missing"). -- **The rail spans the whole lifecycle, not just creation.** After submission the creation - steps collapse into read-only summaries and Monitor (later Results, Files) become the active - steps — this replaces today's `defaultTab` status-jumping logic. With `editable={false}` - (shared/public jobs) the rail renders view-only: no "Change" affordances, no Submit. -- `TAB_NAVIGATION_CONFIG` (`@mat3ra/jode`) grows optional per-tab step metadata (order, label) - so webapp and standalone agree on the sequence. -- The three "Select …" dialogs stay as they are, but open from "Change" affordances on their - steps. -- Layout: rail left (fixed ~260 px, collapses to a horizontal stepper under 760 px — the - compact variant can extend cove's existing `StyledStepper`), content right. Step state - renders with cove `StatusChip` colors from the job-status mapping (1.5). Keep the DOM of tab - panels unchanged where possible so Cypress selectors survive. -- Acceptance: a new user can create and submit a job without opening any dropdown; deep links - via `getRouteQueryTab` still land on the right step; a finished job opens on Monitor/Results - with creation steps summarized; the rail is keyboard-navigable (arrow keys between steps, - visible focus, `aria-current` on the active step). Size: L. - -### 2.2 Context strip (A2) — job-designer - -- New `JobContextStrip` under the header: chips for material (formula · source), workflow - (name · subworkflow/unit counts), compute (cluster · nodes×cores · walltime), estimate - (core-hours ≈ cost). Chips navigate to their step; incomplete chips render in the attention - style. Data comes from the same `getJobReadiness` selector plus the estimate helper (2.3). -- The parent job moves here too: today it renders as a dismissable `Alert` above the tabs - (`Job.jsx#renderParentJob`); it becomes a context chip (parent name · project) with the - remove affordance in its popover — same `setParent` / `unsetParent` model calls (the flow - fixed in SOF-7962). -- Acceptance: on every step, the other selections stay visible; clicking a chip switches step; - a parent-derived job shows the parent chip and no orphaned Alert. Size: S–M. - -### 2.3 Compute redesign: cluster cards, presets, live estimate (B1–B3) — `@mat3ra/ive` - -- Cluster picker becomes selectable cards — cove `SelectableCard` with a `StatusChip` queue - badge — fed by the existing `clusters` prop; job-designer passes an optional - `clusterMetadata` enrichment (pricing, limits, queue wait) injected by the webapp through - `setDependencies()` — standalone falls back to static demo data. Nodes / cores / walltime - become cove `NumericStepperInput`s with min/max from `clusterMetadata`. -- New `ComputeEstimatePanel` built from cove `MetricTile`s and a `SegmentedMeter` for quota: - core-hours = nodes × cores × walltime, price, queue ETA, monthly quota. Pure function - `estimateComputeCost(computeConfiguration, clusterMetadata)` lives beside the panel and is - unit-tested. -- Presets row (Debug / Standard / Production / same-as-last-job) writes through the normal - `onUpdate(compute)` path so undo/save semantics are untouched. -- Validation limits (max nodes, cores per node, queue walltime caps) come from - `clusterMetadata`; violations render inline (not red-on-first-paint — 1.1's touched logic). -- The advanced-options section (`showAdvancedComputeOptions`, gated today by the applications' - `hasAdvancedComputeOptions` in `Job.jsx`) is preserved as a collapsed "Advanced" group below - the cards — redesign must not drop the espresso-class options. -- Acceptance: changing any field updates the estimate synchronously; exceeding a limit flags - the field and the estimate panel; presets fill the form in one click; advanced options remain - reachable; webapp data path and standalone fallback both render. Size: L. - -### 2.4 Preflight at submit (C1) — job-designer (+ webapp data) - -- New `PreflightDialog` opened by Submit: runs ordered checks — material set, workflow renders - (`job.render()` succeeds / template errors empty), compute within `clusterMetadata` limits, - estimated cost vs. remaining quota. Each check row: pass / warn / fail; fails deep-link to - the owning step; warns require acknowledge. Submit proceeds only with zero fails and all - warns acknowledged, then calls the existing `onSubmit` prop. -- Check implementations live in `src/preflight/` as pure async functions - `runPreflightChecks(job, materials, clusterMetadata, quota) → PreflightReport`, injectable so - the webapp can add checks (e.g. balance) via `setDependencies()`. -- Acceptance: submitting an incomplete job is impossible through the UI; every fail row's - action lands on the field that fixes it; checks are unit-tested including the warn/ack flow. - Size: M–L. - -### 2.5 Materials tray and metadata (E1, E2) — job-designer (+ viewer package) - -- `MaterialTab` gains a chips tray above the viewer (add / remove / switch — reusing - `onUpdateIndex`, `onMaterialRemove`, `openAddMaterialsDialog`) and the explicit copy - "N materials → the workflow runs N times". Multi-material switching stops hiding inside the - Workflow tab (the workflow pane's switcher stays for parity but the tray is the primary - affordance). -- Metadata side panel (formula, lattice, space group, atom count, source id) rendered from the - `Material` model next to the injected viewer; the viewer component API is unchanged. -- Acceptance: adding a second material updates the tray, the batch copy, and the context strip; - removing the active material selects a sane neighbor. Size: M. -- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Divergence: - the metadata is read through a defensive `getMaterialSummary()` that omits any field it - cannot read, because `MaterialTab` already renders a fallback for hosts passing a plain - config and made's model getters throw on partial data. Space group is shown only when the - model actually carries a `symmetry` derived property — standata materials generally do not, - so the row is usually absent rather than guessed from the name. - -### 2.6 Save-state honesty (new; relates to UX-498) - -- The header states the truth about persistence: "Saved" only after the entity actually - persisted through `onSave` / the `shouldPersistJobOnUpdate` pipeline, "Unsaved changes" - otherwise, and a leave-guard (browser `beforeunload` + router guard injected via the seam) - when a dirty draft is about to be abandoned. The mockups' "All changes saved" copy is the - target state; showing it without it being true would be worse than today. -- Explicit non-goal here: no new autosave backend — this item only surfaces existing state - honestly. If product wants real autosave (UX-498 direction), that is a separate ticket. -- Acceptance: editing any field flips the indicator to dirty; Save flips it back; closing the - tab with a dirty draft warns; the indicator never claims "Saved" while in-memory state - differs from the persisted entity. Size: S–M. -- **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Dirty state is - marked in the mutating handlers rather than in `persistJob()`, which also runs on mount and - on entering the Workflow tab; and both save paths were routed through a single `saveJob()` - so the flag cannot be cleared by one header and missed by the other. +Built and in review. Its items, what actually shipped, and the divergences from this plan +are recorded in +[`../review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md`](../review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md). ## Phase 3 — The living job (proposals F1, F2, C2, D3, D4) @@ -350,9 +226,11 @@ Additive props with safe defaults everywhere, so no lockstep release is required 1. `cove` (1.5, palette + primitives) — everything else consumes it. 2. `ive` (1.1, 2.3), `workflow-designer` (1.2, 1.3, 3.3), `jove` (3.2) — in parallel, each behind default-off props. -3. `jode` (2.1 step metadata). +3. ~~`jode` (2.1 step metadata)~~ — not needed; the rail derives its sequence from the + readiness selector and reuses the existing tab ids. jode is untouched. 4. `job-designer` — version bumps + the shell work (1.4, 1.6, 2.1, 2.2, 2.4, 2.5, 2.6), - verified in the standalone demo. + verified in the standalone demo. Its `src/computeEstimate.ts` is a stand-in for ive's + canonical estimator and is deleted at this step, once ive ships one. 5. `web-app` — pin bumps, seam wiring (`registerDependencies`), flag flip after the Cypress suite is green. @@ -396,8 +274,11 @@ through an analytics hook injected via the seam (no-op in standalone): ## Open questions 1. Where do pricing and quota live today — is there an existing accounting endpoint the webapp - can inject, or is this new backend work? (Blocks the cost half of 2.3; the limits half can - ship first.) + can inject, or is this new backend work? **Still open, and no longer blocking:** 2.3 and + 2.4 shipped with `clusterMetadata` / `computeQuota` as injected props, demo values in the + standalone apps, and per-tile degradation — core-hours from the job alone, cost only with a + published price, and a preflight row that reports *skip* rather than passing on no + evidence. The question is now what the webapp injects, not whether the UI can wait for it. 2. Should preflight warnings (e.g. convergence sanity) come from workflow model metadata or stay host-injected only? Package-native heuristics risk false alarms. 3. Does the monitor (3.2) poll job properties or can the webapp provide a push channel? Polling From ec584e7b222f9d5602fde35c5a3c2d1b976f2dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:27:00 +0000 Subject: [PATCH 21/33] feat: show the job's lifecycle in the header (phase 3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the status tint on the header icon with cove's JobLifecycleTimeline: Draft → Queued → Running → Finished, each stage carrying its own icon and its timestamp from the status track. The tint is dropped rather than kept alongside — status is now stated once, in the place that says it best. On a draft the timeline also does the work of telling a first-time reader what is coming. Both header paths get it: the webapp's injected EntityHeader organism and the standalone cove fallback. The WorkflowTab's own `iconCls` is a separate surface and is left alone. Needs a @mat3ra/cove release carrying LifecycleTimeline. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 9 +++++++++ dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 17 +++++++++++++++-- src/components/Job.jsx | 24 +++++++++++++++++++++++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index b9182cc..e75f54b 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -108,6 +108,15 @@ declare class Job { * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Where the job is in its life, in the header. + * + * Replaces the status tint on the header icon (`iconCls: text-${statusCls}`), + * which had one glyph carrying "queued", "running" and "errored" alike and + * could say nothing about what had already happened or when. On a draft it + * also does the work of telling a first-time reader what is coming. + */ + renderLifecycleTimeline(): React.JSX.Element; /** * Says whether the job on screen has been persisted. Only while editable: * a read-only view has nothing to save, so the words would be noise. diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index d30f5bd..dc43ffe 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAuIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAiUC;CACJ;kBA7lCiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAwIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAkUC;CACJ;kBAlnCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index b06d2f2..ad53619 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -1,6 +1,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /* eslint-disable jsx-a11y/anchor-is-valid */ import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import { JobLifecycleTimeline } from "@mat3ra/cove/dist/mui/components/lifecycle/LifecycleTimeline"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Alert from "@mui/material/Alert"; import AlertTitle from "@mui/material/AlertTitle"; @@ -540,6 +541,18 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Where the job is in its life, in the header. + * + * Replaces the status tint on the header icon (`iconCls: text-${statusCls}`), + * which had one glyph carrying "queued", "running" and "errored" alike and + * could say nothing about what had already happened or when. On a draft it + * also does the work of telling a first-time reader what is coming. + */ + renderLifecycleTimeline() { + const job = this.state.entity; + return (_jsx(JobLifecycleTimeline, { id: "job-lifecycle-timeline", status: job.status, statusTrack: job.statusTrack })); + } /** * Says whether the job on screen has been persisted. Only while editable: * a read-only view has nothing to save, so the words would be noise. @@ -678,7 +691,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // Save & Exit split button) for production parity; standalone falls back to a // minimal header built on cove's EntityHeader below. const InjectedEntityHeader = getInjectedDeps().EntityHeaderComponent; - return (_jsxs(ErrorBoundary, { FallbackComponent: JobDesignerErrorCard, children: [InjectedEntityHeader ? (_jsxs(InjectedEntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, description: job.description, icon: "entities.job", iconCls: `text-${job.statusCls}`, id: "job-designer-header", saveBtnProps: { + return (_jsxs(ErrorBoundary, { FallbackComponent: JobDesignerErrorCard, children: [InjectedEntityHeader ? (_jsxs(InjectedEntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, description: job.description, icon: "entities.job", id: "job-designer-header", saveBtnProps: { isShown: Boolean(this.props.editable), isLoading: isDesignerLoading, // Read `this.state.entity` at call time (not a render-time `job` @@ -686,7 +699,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // on mount, so a captured entity would forever persist the very // first render's state - see getSaveBtnProps for the full note. onSave: (omitRedirect) => this.saveJob((redirectFlag) => this.props.onSave(redirectFlag), omitRedirect), - }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderPreflightDialog(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), useGuidedDesigner ? (_jsx(JobContextStrip, { steps: readiness.steps, onSelect: this.onReadinessStepSelect, parentJob: parentJobForStrip, onParentRemove: editable ? this.onParentRemove : undefined, estimateLabel: this.estimateLabel })) : null, useGuidedDesigner ? null : (_jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true })), _jsxs(Box, { sx: useGuidedDesigner + }, dropdownProps: dropdownProps, descriptionEditorTitle: "Job Description", isDescriptionEditorHidden: hideDescription, item: job, isDescriptionEditable: isDescriptionEditable, onDescriptionUpdate: this.onDescriptionUpdate, children: [this.renderLifecycleTimeline(), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })) : (_jsxs(EntityHeader, { name: job.name, editable: this.props.editable, onNameUpdate: this.onNameUpdate, isLoading: isDesignerLoading, subtitle: (project === null || project === void 0 ? void 0 : project.name) ? { project: project.name } : undefined, icon: "entities.job", id: "job-designer-header", children: [dropdownProps.isShown && _jsx(Dropdown, { ...dropdownProps }), this.props.editable && _jsx(ButtonMultiSelect, { ...this.getSaveBtnProps() }), this.renderLifecycleTimeline(), this.renderSaveStateIndicator(), this.renderSubmitAction(), headerChildren !== null && headerChildren !== void 0 ? headerChildren : null] })), this.renderTerminateConfirmation(), this.renderPreflightDialog(), this.renderParentJob(), this.renderErrors(), this.renderWarnings(), useGuidedDesigner ? (_jsx(JobContextStrip, { steps: readiness.steps, onSelect: this.onReadinessStepSelect, parentJob: parentJobForStrip, onParentRemove: editable ? this.onParentRemove : undefined, estimateLabel: this.estimateLabel })) : null, useGuidedDesigner ? null : (_jsx(TabsMenu, { tabs: tabs, activeTabIndex: activeTabIndex, variant: "fullWidth", centered: true })), _jsxs(Box, { sx: useGuidedDesigner ? { display: "grid", gridTemplateColumns: { xs: "1fr", md: "auto minmax(0, 1fr)" }, diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 6882ebb..524ab9a 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -1,5 +1,6 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; +import { JobLifecycleTimeline } from "@mat3ra/cove/dist/mui/components/lifecycle/LifecycleTimeline"; import { showWarningAlert } from "@mat3ra/cove/dist/other/alerts"; import Alert from "@mui/material/Alert"; import AlertTitle from "@mui/material/AlertTitle"; @@ -487,6 +488,26 @@ class Job extends mix(React.Component).with( * the dropdown did. Terminate asks first - it kills a running job, and it * used to be a single unconfirmed click. */ + /** + * Where the job is in its life, in the header. + * + * Replaces the status tint on the header icon (`iconCls: text-${statusCls}`), + * which had one glyph carrying "queued", "running" and "errored" alike and + * could say nothing about what had already happened or when. On a draft it + * also does the work of telling a first-time reader what is coming. + */ + renderLifecycleTimeline() { + const job = this.state.entity; + + return ( + + ); + } + /** * Says whether the job on screen has been persisted. Only while editable: * a read-only view has nothing to save, so the words would be noise. @@ -914,7 +935,6 @@ class Job extends mix(React.Component).with( subtitle={project?.name ? { project: project.name } : undefined} description={job.description} icon="entities.job" - iconCls={`text-${job.statusCls}`} id="job-designer-header" saveBtnProps={{ isShown: Boolean(this.props.editable), @@ -936,6 +956,7 @@ class Job extends mix(React.Component).with( isDescriptionEditable={isDescriptionEditable} onDescriptionUpdate={this.onDescriptionUpdate} > + {this.renderLifecycleTimeline()} {this.renderSaveStateIndicator()} {this.renderSubmitAction()} {headerChildren ?? null} @@ -956,6 +977,7 @@ class Job extends mix(React.Component).with( empty menu. */} {dropdownProps.isShown && } {this.props.editable && } + {this.renderLifecycleTimeline()} {this.renderSaveStateIndicator()} {this.renderSubmitAction()} {headerChildren ?? null} From 96a79defeff0591b15d6aff26d52b9482ac75943 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:49:06 +0000 Subject: [PATCH 22/33] feat: thread the unit inspector and host theme to the workflow tab The job-designer half of phase 3.3. Both live in @mat3ra/workflow-designer and are inert on releases predating them, so this lands ahead of that release rather than after it. Tied to `useGuidedDesigner`: the guided layout is a dark shell, and the workflow designer pinning itself to a light theme inside it is exactly the mismatch `useHostTheme` exists to fix. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 5 ++++- dist/components/WorkflowTab.d.ts | 9 ++++++++- dist/components/WorkflowTab.d.ts.map | 2 +- dist/components/WorkflowTab.js | 4 ++-- src/components/Job.jsx | 4 ++++ src/components/WorkflowTab.tsx | 11 +++++++++++ 7 files changed, 31 insertions(+), 6 deletions(-) diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index dc43ffe..e6b5295 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAwIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAkUC;CACJ;kBAlnCiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAwIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAsUC;CACJ;kBAtnCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index ad53619..03c00d2 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -708,7 +708,10 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin : undefined, children: [useGuidedDesigner ? (_jsx(JobReadinessRail, { steps: readiness.steps, activeStepId: this.state.currentTab === TAB_NAVIGATION_CONFIG.compute.id && readiness.isSubmittable ? TAB_NAVIGATION_CONFIG.compute.id - : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount, + : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable, + // Phase 3.3 lives in @mat3ra/workflow-designer; + // inert until a release carrying it is installed. + useUnitInspector: useGuidedDesigner, useHostTheme: useGuidedDesigner })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount, // Phase 2.3 lives in @mat3ra/ive; these are inert // until a release carrying it is installed. useComputeCards: useGuidedDesigner, clusterMetadata: this.getPreflightContext().clusterMetadata, computeQuota: this.getPreflightContext().quota, runs: this.isUsingMaterialsTab diff --git a/dist/components/WorkflowTab.d.ts b/dist/components/WorkflowTab.d.ts index 017796c..e72a46f 100644 --- a/dist/components/WorkflowTab.d.ts +++ b/dist/components/WorkflowTab.d.ts @@ -15,6 +15,13 @@ export type WorkflowTabProps = Pick { if (!applySubworkflowUpdateToWorkflow(workflow, subworkflowOrSchema, materials !== null && materials !== void 0 ? materials : [], metaProperties)) { return; @@ -22,5 +22,5 @@ export default function WorkflowTab({ id, className, role, workflow, adjustable return (_jsx("div", { className: setClass(className), id: id, role: role, children: _jsx(Workflow, { workflow: workflow, showHeader: true, editable: false, adjustable: adjustable, profile: profile, publicAccount: publicAccount, onSubworkflowUnitUpdate: onSubworkflowUnitUpdate, onUnitUpdate: onUnitUpdate, onIsMultiMaterialChanged: onIsMultiMaterialChanged, materials: materials, materialsSet: materialsSet, materialsIndex: materialsIndex, jobHasParent: jobHasParent, onMaterialSwitch: onMaterialSwitch, onUpdate: onWorkflowUpdate, isLoading: isLoading, iconCls: iconCls, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: dialogs, templates: templates, createMetaProperty: createMetaProperty, onRender: onJobRender, renderAtJobLevel: true, workflowRenderGeneration: workflowRenderGeneration, isDescriptionEditable: isDescriptionEditable, jobProperties: jobProperties, // The job has its own Compute tab; without this the same screen // offers two of them and the reader has to guess which one runs. - hideComputeSubTab: true }) })); + hideComputeSubTab: true, useUnitInspector: useUnitInspector, useHostTheme: useHostTheme }) })); } diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 524ab9a..13b8e89 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -1092,6 +1092,10 @@ class Job extends mix(React.Component).with( createMetaProperty={createMetaProperty} jobProperties={jobProperties} isDescriptionEditable={isDescriptionEditable} + // Phase 3.3 lives in @mat3ra/workflow-designer; + // inert until a release carrying it is installed. + useUnitInspector={useGuidedDesigner} + useHostTheme={useGuidedDesigner} /> )} {isCurrentTabCompute && ( diff --git a/src/components/WorkflowTab.tsx b/src/components/WorkflowTab.tsx index 5de1e03..e8ccae5 100644 --- a/src/components/WorkflowTab.tsx +++ b/src/components/WorkflowTab.tsx @@ -35,6 +35,13 @@ export type WorkflowTabProps = Pick< accountUsers: any[]; accountUsersIsLoading: boolean; isDescriptionEditable: boolean; + /** + * Phase 3.3 (@mat3ra/workflow-designer): clicking a unit opens its settings + * beside the flowchart, and the designer inherits this shell's theme instead + * of forcing a light one. Ignored by releases predating them. + */ + useUnitInspector?: boolean; + useHostTheme?: boolean; }; export default function WorkflowTab({ @@ -65,6 +72,8 @@ export default function WorkflowTab({ jobHasParent = false, isDescriptionEditable, workflowRenderGeneration, + useUnitInspector, + useHostTheme, }: WorkflowTabProps) { const onSubworkflowUnitUpdate = useCallback( (subworkflowOrSchema: SubworkflowDesignerUpdate) => { @@ -134,6 +143,8 @@ export default function WorkflowTab({ // The job has its own Compute tab; without this the same screen // offers two of them and the reader has to guess which one runs. hideComputeSubTab + useUnitInspector={useUnitInspector} + useHostTheme={useHostTheme} /> ); From 6340b1135781554769fb195062c74d9c20451f60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 04:08:02 +0000 Subject: [PATCH 23/33] feat: land on the monitor after submit, and feed it (phase 3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job-designer half. A submitted job left the reader on the form they had just finished with; now the designer moves to the monitor — but on the status transition, not on the click. Switching at the click would land on a Results tab the conditional tab map has not enabled yet, because the job is still `pre-submission` until the server says otherwise. ResultsTab gets the job's units and, where the host injects a `getJobLogTail` reader, its log. Without one it says the deployment has no log feed rather than showing an empty box that reads as a silent job. The demo gains a "Job: draft / running" toggle. Its submit API is a stub, so a running job was unreachable and the monitor, the lifecycle timeline past Draft, and the rail's Monitor step could never be reviewed. The container builds its store from the job it is first given, so the toggle also has to remount it. Verified in the demo: toggling to running moves the lifecycle to Draft[done] → Queued[done] → Running[current], swaps the rail's "Review & submit" for "Monitor · Running" and "Files", removes Submit, and renders the monitor over the workflow's five units. Needs a @mat3ra/jove release carrying RunMonitor. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 2 ++ dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 28 +++++++++++++++++++++++++--- dist/standalone/index.js | 27 +++++++++++++++++++++++---- src/components/Job.jsx | 30 +++++++++++++++++++++++++++++- src/standalone/index.tsx | 34 +++++++++++++++++++++++++++++++--- 6 files changed, 111 insertions(+), 12 deletions(-) diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index e75f54b..ba40611 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -61,6 +61,8 @@ declare class Job { clusterMetadata: any; quota: any; }; + /** Every unit across the job's subworkflows, in workflow order. */ + get workflowUnits(): any; confirmPreflightSubmit: () => void; get saveStateInputs(): { hasUnsavedChanges: any; diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index e6b5295..7d520de 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAwIA;IAMI,wBAcC;IAZG,WAQC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mCAGE;IAEF;;;;MAMC;IAED,yCAOC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BAsUC;CACJ;kBAtnCiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAwIA;IAMI,wBAeC;IAbG,WASC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAGC;IAED;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAE7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mEAAmE;IACnE,yBAMC;IAED,mCAOE;IAEF;;;;MAMC;IAED,yCAeC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAGE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BA4UC;CACJ;kBAlpCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index 03c00d2..d175b8b 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -147,7 +147,11 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin }; this.confirmPreflightSubmit = () => { var _a, _b; - this.setState({ isPreflightOpen: false }); + // Navigation waits for the status to actually change (see + // componentDidUpdate). Switching now would land the reader on a Results + // tab that the conditional tab map has not enabled yet, because the job + // is still `pre-submission` until the server says otherwise. + this.setState({ isPreflightOpen: false, hasSubmitted: true }); (_b = (_a = this.props).onSubmit) === null || _b === void 0 ? void 0 : _b.call(_a); }; this.onComputeUpdate = (compute) => { @@ -370,6 +374,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin isTerminateConfirmationOpen: false, hasUnsavedChanges: false, isPreflightOpen: false, + hasSubmitted: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -481,6 +486,12 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin const runs = this.isUsingMaterialsTab ? (_b = (_a = this.props.materials) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0 : 1; return formatEstimate(estimateComputeUsage(this.state.entity.compute, clusterMetadata, runs)); } + /** Every unit across the job's subworkflows, in workflow order. */ + get workflowUnits() { + var _a, _b; + const subworkflows = (_b = (_a = this.state.entity.workflow) === null || _a === void 0 ? void 0 : _a.subworkflows) !== null && _b !== void 0 ? _b : []; + return subworkflows.flatMap((subworkflow) => { var _a, _b; return (_b = (_a = subworkflow === null || subworkflow === void 0 ? void 0 : subworkflow.unitsInstances) !== null && _a !== void 0 ? _a : subworkflow === null || subworkflow === void 0 ? void 0 : subworkflow.units) !== null && _b !== void 0 ? _b : []; }); + } get saveStateInputs() { return { hasUnsavedChanges: this.state.hasUnsavedChanges, @@ -492,6 +503,14 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin if (prevProps.job !== this.props.job) { this.setState({ entity: this.props.job }); } + // The job the reader just submitted has left their hands; what they want + // next is to watch it run, not the form they finished with (C2). Fires on + // the transition rather than on the click, so the monitor is reachable by + // the time we get there. + if (this.state.hasSubmitted && !this.props.job.isInInitialStatus) { + this.setState({ hasSubmitted: false }); + this.setCurrentTab(TAB_NAVIGATION_CONFIG.results.id); + } if (shouldPersistJobOnUpdate(prevProps, this.props)) { this.persistJob(); } @@ -642,7 +661,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin closeSelectParentJobDialog(); } render() { - var _a, _b, _c, _d; + var _a, _b, _c, _d, _e, _f; const { editable, isLoading, hideDescription, material, index, length, onUpdateIndex, onMaterialRemove, datasetConfig, allowedWorkflows, onWorkflowSelect, materials, materialsSet, metaProperties, onIsMultiMaterialChanged, onMaterialSwitch, onOutputUpdateRequest, clusters, profile, accountUsers, accountUsersIsLoading, workflowDialogs, publicAccount, project, templates, resultsProperties, jobProperties, createMetaProperty, fetchMaterials, renderGeneration, MaterialViewerComponent, /** Optional children rendered in the right side of the EntityHeader (selectors, export button, etc.). */ headerChildren, } = this.props; @@ -716,7 +735,10 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin // until a release carrying it is installed. useComputeCards: useGuidedDesigner, clusterMetadata: this.getPreflightContext().clusterMetadata, computeQuota: this.getPreflightContext().quota, runs: this.isUsingMaterialsTab ? Math.max((_d = materials === null || materials === void 0 ? void 0 : materials.length) !== null && _d !== void 0 ? _d : 1, 1) - : 1 })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) })] })] })); + : 1 })), isCurrentTabResults && (_jsx(ResultsTab, { className: `jobs-view ${isActive(isCurrentTabResults)}`, id: TAB_NAVIGATION_CONFIG.results.id, role: "tabpanel", job: job, material: material, publicAccount: publicAccount, profile: profile, resultsProperties: resultsProperties, jobProperties: jobProperties, fetchMaterials: fetchMaterials, MaterialComponent: MaterialViewerComponent, fileUtils: getFileUtils(), DataGridComponent: getInjectedDeps().DataGridComponent, + // Phase 3.2 lives in @mat3ra/jove; inert until a + // release carrying it is installed. + showRunMonitor: useGuidedDesigner && !job.isInInitialStatus, units: this.workflowUnits, logText: (_f = (_e = getInjectedDeps()).getJobLogTail) === null || _f === void 0 ? void 0 : _f.call(_e, job), hasLogSource: Boolean(getInjectedDeps().getJobLogTail) })), isCurrentTabFiles && (_jsx(FilesTab, { className: `jobs-view ${isActive(isCurrentTabFiles)}`, id: TAB_NAVIGATION_CONFIG.files.id, role: "tabpanel", job: job }))] })) })] })] })); } } export default Job; diff --git a/dist/standalone/index.js b/dist/standalone/index.js index 4ac0b17..372625f 100644 --- a/dist/standalone/index.js +++ b/dist/standalone/index.js @@ -150,6 +150,8 @@ const DEMO_CLUSTER_METADATA = [ queueWaitMinutes: 25, }, ]; +/** Fixed unix seconds so the simulated run reads the same on every reload. */ +const SIMULATED_START = 1755000000; const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; function App() { const allWorkflowJsons = useMemo(() => { var _a; return (_a = new WorkflowStandata().getAll()) !== null && _a !== void 0 ? _a : []; }, []); @@ -167,6 +169,10 @@ function App() { }, []); // Phase 2 layout, opt-in: the demo is where it gets reviewed before any host flips it on. const [useGuidedDesigner, setUseGuidedDesigner] = useState(true); + // A submitted job cannot be reached in the demo — its submit API is a stub — + // so the monitor, the lifecycle timeline past Draft, and the rail's Monitor + // step would never be reviewable. This starts the job already running. + const [isRunSimulated, setIsRunSimulated] = useState(false); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m) => { var _a; return /silicon|^si\b/i.test((_a = m.name) !== null && _a !== void 0 ? _a : ""); }); return idx >= 0 ? idx : 0; @@ -189,7 +195,18 @@ function App() { // acquire one - leaving Submit permanently blocked on "Save the job" and // the preflight unreachable. The save-state indicator is unaffected; it // tracks edits, not identity. - const newJob = new Job({ _id: "standalone-job-1", name, status: "pre-submission" }); + const newJob = new Job({ + _id: "standalone-job-1", + name, + status: isRunSimulated ? "active" : "pre-submission", + statusTrack: isRunSimulated + ? [ + { status: "pre-submission", trackedAt: SIMULATED_START }, + { status: "submitted", trackedAt: SIMULATED_START + 60 }, + { status: "active", trackedAt: SIMULATED_START + 180 }, + ] + : [], + }); newJob.setWorkflow(wodeWorkflow); newJob.setMaterial(selectedMaterial); // job-designer's own reducers (inherited from the webapp's original Job @@ -208,7 +225,7 @@ function App() { return null; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wodeWorkflow, selectedMaterial]); + }, [wodeWorkflow, selectedMaterial, isRunSimulated]); if (job) jobRef.current = job; const handleExportJson = () => { @@ -223,7 +240,9 @@ function App() { .toLowerCase()}-${Date.now()}.json`; downloadJson(raw, safeFilename); }; - const designerKey = `${workflowIndex}-${materialIndex}`; + // Remount on a simulated-run flip too: the container builds its redux store from + // the job it is first given, so a new Job instance alone would not be picked up. + const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}`; if (!wodeWorkflow || !selectedMaterial || !job) { return (_jsx(Box, { p: 4, children: _jsx(Typography, { children: "Loading..." }) })); } @@ -239,7 +258,7 @@ function App() { }) })] })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(ScienceIcon, { fontSize: "small", sx: { color: "secondary.main", flexShrink: 0 } }), _jsxs(FormControl, { size: "small", sx: { minWidth: 240 }, children: [_jsx(InputLabel, { id: "material-select-label", children: "Material" }), _jsx(Select, { labelId: "material-select-label", value: materialIndex, label: "Material", onChange: (e) => setMaterialIndex(Number(e.target.value)), children: allMaterialJsons.map((mat, i) => { var _a, _b; return (_jsx(MenuItem, { value: i, children: (_b = (_a = mat === null || mat === void 0 ? void 0 : mat.name) !== null && _a !== void 0 ? _a : mat === null || mat === void 0 ? void 0 : mat.formula) !== null && _b !== void 0 ? _b : `Material ${i + 1}` }, i)); - }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setUseGuidedDesigner((isOn) => !isOn), sx: { mr: 1 }, children: useGuidedDesigner ? "Guided layout: on" : "Guided layout: off" }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { + }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setUseGuidedDesigner((isOn) => !isOn), sx: { mr: 1 }, children: useGuidedDesigner ? "Guided layout: on" : "Guided layout: off" }), _jsx(Button, { size: "small", variant: isRunSimulated ? "contained" : "outlined", onClick: () => setIsRunSimulated((on) => !on), "data-tid": "simulate-run-toggle", children: isRunSimulated ? "Job: running" : "Job: draft" }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { borderColor: "rgba(124,77,255,0.5)", color: "primary.main", "&:hover": { diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 13b8e89..5588cd9 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -150,6 +150,7 @@ class Job extends mix(React.Component).with( isTerminateConfirmationOpen: false, hasUnsavedChanges: false, isPreflightOpen: false, + hasSubmitted: false, }; this.onEntityUpdate = this.props.onUpdate; this.onWorkflowUpdate = this.onWorkflowUpdate.bind(this); @@ -308,8 +309,21 @@ class Job extends mix(React.Component).with( quota: getInjectedDeps().computeQuota ?? this.props.computeQuota, }); + /** Every unit across the job's subworkflows, in workflow order. */ + get workflowUnits() { + const subworkflows = this.state.entity.workflow?.subworkflows ?? []; + + return subworkflows.flatMap( + (subworkflow) => subworkflow?.unitsInstances ?? subworkflow?.units ?? [], + ); + } + confirmPreflightSubmit = () => { - this.setState({ isPreflightOpen: false }); + // Navigation waits for the status to actually change (see + // componentDidUpdate). Switching now would land the reader on a Results + // tab that the conditional tab map has not enabled yet, because the job + // is still `pre-submission` until the server says otherwise. + this.setState({ isPreflightOpen: false, hasSubmitted: true }); this.props.onSubmit?.(); }; @@ -325,6 +339,14 @@ class Job extends mix(React.Component).with( if (prevProps.job !== this.props.job) { this.setState({ entity: this.props.job }); } + // The job the reader just submitted has left their hands; what they want + // next is to watch it run, not the form they finished with (C2). Fires on + // the transition rather than on the click, so the monitor is reachable by + // the time we get there. + if (this.state.hasSubmitted && !this.props.job.isInInitialStatus) { + this.setState({ hasSubmitted: false }); + this.setCurrentTab(TAB_NAVIGATION_CONFIG.results.id); + } if (shouldPersistJobOnUpdate(prevProps, this.props)) { this.persistJob(); } @@ -1140,6 +1162,12 @@ class Job extends mix(React.Component).with( MaterialComponent={MaterialViewerComponent} fileUtils={getFileUtils()} DataGridComponent={getInjectedDeps().DataGridComponent} + // Phase 3.2 lives in @mat3ra/jove; inert until a + // release carrying it is installed. + showRunMonitor={useGuidedDesigner && !job.isInInitialStatus} + units={this.workflowUnits} + logText={getInjectedDeps().getJobLogTail?.(job)} + hasLogSource={Boolean(getInjectedDeps().getJobLogTail)} /> )} {isCurrentTabFiles && ( diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 76ba0f6..233416f 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -174,6 +174,9 @@ const DEMO_CLUSTER_METADATA = [ }, ]; +/** Fixed unix seconds so the simulated run reads the same on every reload. */ +const SIMULATED_START = 1_755_000_000; + const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; function App() { @@ -196,6 +199,10 @@ function App() { ); // Phase 2 layout, opt-in: the demo is where it gets reviewed before any host flips it on. const [useGuidedDesigner, setUseGuidedDesigner] = useState(true); + // A submitted job cannot be reached in the demo — its submit API is a stub — + // so the monitor, the lifecycle timeline past Draft, and the rail's Monitor + // step would never be reviewable. This starts the job already running. + const [isRunSimulated, setIsRunSimulated] = useState(false); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m: any) => /silicon|^si\b/i.test(m.name ?? "")); return idx >= 0 ? idx : 0; @@ -224,7 +231,18 @@ function App() { // acquire one - leaving Submit permanently blocked on "Save the job" and // the preflight unreachable. The save-state indicator is unaffected; it // tracks edits, not identity. - const newJob = new Job({ _id: "standalone-job-1", name, status: "pre-submission" }); + const newJob = new Job({ + _id: "standalone-job-1", + name, + status: isRunSimulated ? "active" : "pre-submission", + statusTrack: isRunSimulated + ? [ + { status: "pre-submission", trackedAt: SIMULATED_START }, + { status: "submitted", trackedAt: SIMULATED_START + 60 }, + { status: "active", trackedAt: SIMULATED_START + 180 }, + ] + : [], + }); newJob.setWorkflow(wodeWorkflow); newJob.setMaterial(selectedMaterial); @@ -244,7 +262,7 @@ function App() { return null; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wodeWorkflow, selectedMaterial]); + }, [wodeWorkflow, selectedMaterial, isRunSimulated]); if (job) jobRef.current = job; @@ -259,7 +277,9 @@ function App() { downloadJson(raw, safeFilename); }; - const designerKey = `${workflowIndex}-${materialIndex}`; + // Remount on a simulated-run flip too: the container builds its redux store from + // the job it is first given, so a new Job instance alone would not be picked up. + const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}`; if (!wodeWorkflow || !selectedMaterial || !job) { return ( @@ -345,6 +365,14 @@ function App() { > {useGuidedDesigner ? "Guided layout: on" : "Guided layout: off"} + ) : null} {needsAcknowledgement ? ( @@ -100,12 +102,12 @@ function PreflightRowView({ onClick={onAcknowledge} id={`job-preflight-ack-${row.id}`} > - Acknowledge + {getMessage("preflight.acknowledge")} ) : null} {isAcknowledged ? ( - Acknowledged + {getMessage("preflight.acknowledged")} ) : null} {row.fix ? ( @@ -170,6 +172,12 @@ export default function PreflightDialog({ try { const nextReport = await runPreflightChecks(getContextRef.current()); setReport(nextReport); + // Which checks fail, not just how many — a check that fails often names + // a step whose affordances still do not work. + trackEvent( + ANALYTICS_EVENTS.preflightCompleted, + summarizeReportForAnalytics(nextReport), + ); // Acknowledgements answer a specific report. Keep the ones whose // warning is still there — re-running after fixing something else // should not make the reader dismiss the same caveat again — and drop @@ -194,6 +202,8 @@ export default function PreflightDialog({ const canSubmit = canSubmitFromReport(report, acknowledged); const handleFix = (stepId: string) => { + // Low usage against a high fail rate means the deep link is not being found. + trackEvent(ANALYTICS_EVENTS.preflightFixFollowed, { stepId }); onClose(); onGoToStep(stepId); }; @@ -202,7 +212,7 @@ export default function PreflightDialog({ - Preflight + {getMessage("preflight.title")} - {isRunning ? "Running checks…" : getReportSummary(report, acknowledged)} + {isRunning + ? getMessage("preflight.running") + : getReportSummary(report, acknowledged)} @@ -227,22 +239,27 @@ export default function PreflightDialog({ onToggleExpanded={() => setExpandedRowId(expandedRowId === row.id ? null : row.id) } - onAcknowledge={() => setAcknowledged([...acknowledged, row.id])} + onAcknowledge={() => { + trackEvent(ANALYTICS_EVENTS.preflightWarningAcknowledged, { + checkId: row.id, + }); + setAcknowledged([...acknowledged, row.id]); + }} onFix={handleFix} /> ))} {!report && !isRunning ? ( - No checks ran. + {getMessage("preflight.noChecks")} ) : null} - + diff --git a/src/jobReadiness.ts b/src/jobReadiness.ts index 27cda8c..9583661 100644 --- a/src/jobReadiness.ts +++ b/src/jobReadiness.ts @@ -1,5 +1,6 @@ import { type ClusterMetadata, findClusterMetadata, parseWalltimeHours } from "./computeEstimate"; import { getSubmitBlockers, type SubmittableJob } from "./jobSubmission"; +import { getMessage } from "./messages"; /** * What the job still needs, as a sequence of steps. @@ -69,32 +70,36 @@ export interface JobReadinessOptions { const REVIEW_STEP_ID = "review"; function describeMaterials(materials: any[], parentJobName?: string): string { - if (parentJobName) return `From parent job ${parentJobName}`; - if (materials.length === 0) return "No material selected"; + if (parentJobName) return getMessage("readiness.material.fromParent", { name: parentJobName }); + if (materials.length === 0) return getMessage("readiness.material.empty"); if (materials.length === 1) { const [material] = materials; - return material?.formula ?? material?.name ?? "1 material"; + return material?.formula ?? material?.name ?? getMessage("readiness.material.single"); } - return `${materials.length} materials — runs ${materials.length} times`; + return getMessage("readiness.material.batch", { count: materials.length }); } function describeWorkflow(workflow: any): string { const subworkflows = workflow?.subworkflows ?? []; - if (!subworkflows.length) return "No workflow selected"; + if (!subworkflows.length) return getMessage("readiness.workflow.empty"); const unitCount = subworkflows.reduce( (total: number, subworkflow: any) => total + (subworkflow?.units?.length ?? 0), 0, ); - const name = workflow?.name ?? `${subworkflows.length} subworkflows`; + const name = + workflow?.name ?? + getMessage("readiness.workflow.subworkflowCount", { count: subworkflows.length }); - return unitCount ? `${name} · ${unitCount} units` : name; + return unitCount + ? getMessage("readiness.workflow.withUnits", { name, count: unitCount }) + : name; } function describeCompute(compute: any): string { const clusterName = compute?.cluster?.fqdn; - if (!clusterName) return "Cluster and resources needed"; + if (!clusterName) return getMessage("readiness.compute.empty"); const resources = [compute?.nodes, compute?.ppn].every((value) => value) ? `${compute.nodes}×${compute.ppn}` @@ -119,17 +124,19 @@ function getComputeLimitViolations(compute: any, clusterMetadata: ClusterMetadat const violations: string[] = []; if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { - violations.push(`over the ${limits.maxNodes}-node limit`); + violations.push(getMessage("readiness.compute.overNodes", { limit: limits.maxNodes })); } if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { - violations.push(`over ${limits.maxPpn} cores per node`); + violations.push(getMessage("readiness.compute.overPpn", { limit: limits.maxPpn })); } if ( limits.maxWalltimeHours !== undefined && walltimeHours !== undefined && walltimeHours > limits.maxWalltimeHours ) { - violations.push(`over the ${limits.maxWalltimeHours} h queue limit`); + violations.push( + getMessage("readiness.compute.overWalltime", { limit: limits.maxWalltimeHours }), + ); } return violations; @@ -160,23 +167,23 @@ function getCreationSteps({ const hasMaterial = materials.length > 0 || Boolean(parentJobName); steps.push({ id: "material", - label: "Material", + label: getMessage("readiness.material.label"), state: hasMaterial ? "complete" : "empty", summary: describeMaterials(materials, parentJobName), }); } else { steps.push({ id: "dataset", - label: "Dataset", + label: getMessage("readiness.dataset.label"), state: datasetConfig ? "complete" : "empty", - summary: datasetConfig?.name ?? "No dataset selected", + summary: datasetConfig?.name ?? getMessage("readiness.dataset.empty"), }); } const hasWorkflow = Boolean(job.workflow?.subworkflows?.length); steps.push({ id: "workflow", - label: "Workflow", + label: getMessage("readiness.workflow.label"), state: hasWorkflow ? "complete" : "empty", summary: describeWorkflow(job.workflow), }); @@ -185,7 +192,7 @@ function getCreationSteps({ const violations = hasCompute ? getComputeLimitViolations(job.compute, clusterMetadata) : []; steps.push({ id: "compute", - label: "Compute", + label: getMessage("readiness.compute.label"), state: hasCompute && !violations.length ? "complete" : "attention", summary: violations.length ? violations.join(" · ") : describeCompute(job.compute), }); @@ -207,8 +214,8 @@ function withLimitBlocker( const computeStep = steps.find((step) => step.id === "compute"); if (!hasCluster || computeStep?.state !== "attention") return blockers; - const limitBlocker = "Bring compute within the cluster's limits"; - const saveIndex = blockers.indexOf("Save the job"); + const limitBlocker = getMessage("blocker.computeLimits"); + const saveIndex = blockers.indexOf(getMessage("blocker.save")); if (saveIndex === -1) return [...blockers, limitBlocker]; return [...blockers.slice(0, saveIndex), limitBlocker, ...blockers.slice(saveIndex)]; @@ -237,12 +244,12 @@ function getReviewSummary({ isSubmittable: boolean; blockingReasons: string[]; }): string { - if (!editable) return "View only"; - if (isSubmittable) return "Ready to submit"; + if (!editable) return getMessage("readiness.review.viewOnly"); + if (isSubmittable) return getMessage("readiness.review.ready"); return blockingReasons.length === 1 - ? "1 step remaining" - : `${blockingReasons.length} steps remaining`; + ? getMessage("readiness.review.oneRemaining") + : getMessage("readiness.review.remaining", { count: blockingReasons.length }); } export function getJobReadiness({ @@ -284,11 +291,20 @@ export function getJobReadiness({ // of what ran, and what matters is what it is doing. steps.push({ id: "results", - label: job.isInFinalStatus ? "Results" : "Monitor", + label: getMessage( + job.isInFinalStatus ? "readiness.results.label" : "readiness.monitor.label", + ), state: "complete", - summary: job.isInFinalStatus ? "Outputs and properties" : "Running", + summary: getMessage( + job.isInFinalStatus ? "readiness.results.summary" : "readiness.monitor.running", + ), + }); + steps.push({ + id: "files", + label: getMessage("readiness.files.label"), + state: "complete", + summary: getMessage("readiness.files.summary"), }); - steps.push({ id: "files", label: "Files", state: "complete", summary: "Job directory" }); return { steps, isSubmittable: false, blockingReasons: [], isRunOrFinished }; } @@ -297,7 +313,7 @@ export function getJobReadiness({ steps.push({ id: REVIEW_STEP_ID, - label: "Review & submit", + label: getMessage("readiness.review.label"), state: getReviewState({ editable, isSubmittable }), summary: getReviewSummary({ editable, isSubmittable, blockingReasons }), }); diff --git a/src/jobSubmission.ts b/src/jobSubmission.ts index 476962d..59cdb83 100644 --- a/src/jobSubmission.ts +++ b/src/jobSubmission.ts @@ -12,6 +12,8 @@ * same answer, and the three must never disagree about whether a job is ready. */ +import { getMessage } from "./messages"; + /** Minimal shape this module needs; the real entity is jode's `Job`. */ export interface SubmittableJob { id?: string; @@ -45,21 +47,21 @@ export function getSubmitBlockers({ const blockers: string[] = []; if (isUsingMaterials && materials.length === 0) { - blockers.push("Select a material"); + blockers.push(getMessage("blocker.material")); } if (!job.workflow?.subworkflows?.length) { - blockers.push("Select a workflow"); + blockers.push(getMessage("blocker.workflow")); } if (!job.compute?.cluster?.fqdn) { - blockers.push("Configure compute"); + blockers.push(getMessage("blocker.compute")); } // Last, because it is the one the reader fixes by pressing the button next // to Submit rather than by going somewhere else. if (!job.id) { - blockers.push("Save the job"); + blockers.push(getMessage("blocker.save")); } return blockers; @@ -84,7 +86,7 @@ export function formatBlockedReason(blockers: string[]): string | null { if (blockers.length === 0) return null; if (blockers.length === 1) return blockers[0]; - return `${blockers[0]} (+${blockers.length - 1} more)`; + return getMessage("blocker.more", { first: blockers[0], count: blockers.length - 1 }); } /** The same line, for callers holding a job rather than a readiness report. */ diff --git a/src/materialSummary.ts b/src/materialSummary.ts index 3134c99..9b147e5 100644 --- a/src/materialSummary.ts +++ b/src/materialSummary.ts @@ -13,6 +13,8 @@ * read through {@link readSafely} and simply omitted when unavailable. */ +import { getMessage } from "./messages"; + export interface MaterialSource { id: string; name?: string; @@ -131,7 +133,7 @@ export function getMaterialSummary(material: any): MaterialSummary { * A materials set silently turns one job into N, which the designer never says. */ export function getBatchDescription(materialCount: number): string { - if (materialCount <= 1) return "The workflow runs once."; + if (materialCount <= 1) return getMessage("materials.runsOnce"); - return `${materialCount} materials — the workflow runs ${materialCount} times, once per material.`; + return getMessage("materials.runsPerMaterial", { count: materialCount }); } diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..8490897 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,156 @@ +import { getInjectedDeps } from "./setDependencies"; + +/** + * Every sentence the guided designer says, in one place. + * + * The webapp localizes through TAPi18n; this package cannot import it, and a + * string typed inline in a component is a string no translator will ever find. + * So each one lives here with an English default and a key, and the host + * substitutes its own resolver through `setDependencies({ translate })`. + * + * Interpolation matters more than it looks. "3 materials — runs 3 times" is not + * a sentence a catalogue can hold as a fragment plus a number: languages put the + * count in different places and inflect the noun differently. Every message that + * varies takes named parameters and stays one string, so a translation can move + * them. + * + * Keys are grouped by where they are said, not by the component that says them — + * the same readiness summary appears in the rail, the context strip and the + * preflight, and it must read identically in all three. + */ + +export type MessageParams = Record; + +/** Resolves a key to a localized string. Returns undefined to fall back. */ +export type TranslateFunction = (key: string, params?: MessageParams) => string | undefined; + +export const MESSAGES = { + // Readiness steps — the rail's labels and one-line summaries. + "readiness.material.label": "Material", + "readiness.material.empty": "No material selected", + "readiness.material.fromParent": "From parent job {name}", + "readiness.material.single": "1 material", + "readiness.material.batch": "{count} materials — runs {count} times", + "readiness.dataset.label": "Dataset", + "readiness.dataset.empty": "No dataset selected", + "readiness.workflow.label": "Workflow", + "readiness.workflow.empty": "No workflow selected", + "readiness.workflow.withUnits": "{name} · {count} units", + "readiness.workflow.subworkflowCount": "{count} subworkflows", + "readiness.compute.label": "Compute", + "readiness.compute.empty": "Cluster and resources needed", + "readiness.compute.overNodes": "over the {limit}-node limit", + "readiness.compute.overPpn": "over {limit} cores per node", + "readiness.compute.overWalltime": "over the {limit} h queue limit", + "readiness.review.label": "Review & submit", + "readiness.review.ready": "Ready to submit", + "readiness.review.viewOnly": "View only", + "readiness.review.oneRemaining": "1 step remaining", + "readiness.review.remaining": "{count} steps remaining", + "readiness.monitor.label": "Monitor", + "readiness.monitor.running": "Running", + "readiness.results.label": "Results", + "readiness.results.summary": "Outputs and properties", + "readiness.files.label": "Files", + "readiness.files.summary": "Job directory", + + // Why Submit is disabled. Read by the button's tooltip and the preflight. + "blocker.material": "Select a material", + "blocker.dataset": "Select a dataset", + "blocker.workflow": "Select a workflow", + "blocker.compute": "Configure compute", + "blocker.computeLimits": "Bring compute within the cluster's limits", + "blocker.save": "Save the job", + "blocker.more": "{first} (+{count} more)", + + // Preflight rows. + "preflight.title": "Preflight", + "preflight.running": "Running checks…", + "preflight.allPassed": "All checks passed", + "preflight.oneProblem": "1 problem to fix", + "preflight.problems": "{count} problems to fix", + "preflight.oneWarning": "1 warning to acknowledge", + "preflight.warnings": "{count} warnings to acknowledge", + "preflight.noChecks": "No checks ran.", + "preflight.back": "Back to designer", + "preflight.rerun": "Re-run checks", + "preflight.submit": "Submit job", + "preflight.details": "Details", + "preflight.acknowledge": "Acknowledge", + "preflight.acknowledged": "Acknowledged", + "preflight.checkFailed": "This check could not run", + "preflight.inputs.datasetOk": "Dataset job — materials not required", + "preflight.inputs.chooseDataset": "Choose a dataset", + "preflight.inputs.chooseMaterial": "Choose a material", + "preflight.inputs.batch": "{count} materials — the workflow runs {count} times", + "preflight.workflow.label": "Workflow renders", + "preflight.workflow.noRenderer": "{count} units — rendering not available here", + "preflight.workflow.failed": "A unit's input template failed to render", + "preflight.workflow.open": "Open the workflow", + "preflight.workflow.ok": "{count} units · all input templates render", + "preflight.compute.label": "Compute within limits", + "preflight.compute.noCluster": "No cluster selected", + "preflight.compute.adjust": "Adjust compute", + "preflight.compute.noLimits": "{cluster} · {resources} — no published limits to check against", + "preflight.compute.overNodes": "{nodes} nodes exceeds the {limit}-node limit", + "preflight.compute.overPpn": "{ppn} cores per node exceeds the limit of {limit}", + "preflight.compute.overWalltime": "walltime {walltime} exceeds the queue limit of {limit}", + "preflight.budget.label": "Budget", + "preflight.budget.incomplete": "Set nodes, cores and a walltime to estimate the cost", + "preflight.budget.overQuota": "{usage} — only {remaining} left this month", + "preflight.budget.mostOfQuota": "{usage} — more than half of the remaining {remaining}", + "preflight.budget.remainingAfter": "{usage} — {left} would remain", + "preflight.budget.reduce": "Reduce resources", + "preflight.budget.wouldRemain": "{left} would be left after this job.", + "preflight.saved.label": "Saved", + "preflight.saved.ok": "Job is saved", + "preflight.saved.never": "The job has never been saved", + + // Save state. + "saveState.saved": "All changes saved", + "saveState.unsaved": "Unsaved changes", + "saveState.saving": "Saving…", + + // Materials tray and metadata. + "materials.runsOnce": "The workflow runs once.", + "materials.runsPerMaterial": + "{count} materials — the workflow runs {count} times, once per material.", +} as const; + +export type MessageKey = keyof typeof MESSAGES; + +/** + * Fills `{name}` placeholders. Unknown placeholders are left alone rather than + * blanked: a translation that names a parameter this call site does not provide + * should show the gap, not silently swallow it. + */ +function interpolate(template: string, params?: MessageParams): string { + if (!params) return template; + + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** + * The localized string for a key. + * + * Falls back to English whenever the host has no resolver, or its resolver + * returns nothing for this key — a missing translation must show the sentence, + * never the key. + */ +export function getMessage(key: MessageKey, params?: MessageParams): string { + const { translate } = getInjectedDeps() as { translate?: TranslateFunction }; + + if (typeof translate === "function") { + try { + const translated = translate(key, params); + if (translated) return interpolate(translated, params); + } catch { + // A host resolver that throws must not take the designer's copy with + // it; English is always available. + } + } + + return interpolate(MESSAGES[key], params); +} diff --git a/src/preflight/checks.ts b/src/preflight/checks.ts index 24439cc..0b91bbd 100644 --- a/src/preflight/checks.ts +++ b/src/preflight/checks.ts @@ -6,6 +6,7 @@ import { parseWalltimeHours, } from "../computeEstimate"; import { getMaterialSummary } from "../materialSummary"; +import { getMessage } from "../messages"; import type { PreflightCheck, PreflightRow } from "./types"; /** @@ -44,31 +45,35 @@ export const checkInputs: PreflightCheck = async ({ return { id: "inputs", - label: "Dataset", + label: getMessage("readiness.dataset.label"), state: hasDataset ? "pass" : "fail", - detail: hasDataset ? "Dataset job — materials not required" : "No dataset selected", - fix: hasDataset ? undefined : { label: "Choose a dataset", stepId: "dataset" }, + detail: getMessage( + hasDataset ? "preflight.inputs.datasetOk" : "readiness.dataset.empty", + ), + fix: hasDataset + ? undefined + : { label: getMessage("preflight.inputs.chooseDataset"), stepId: "dataset" }, }; } if (materials.length === 0) { return { id: "inputs", - label: "Material", + label: getMessage("readiness.material.label"), state: "fail", - detail: "No material selected", - fix: { label: "Choose a material", stepId: "material" }, + detail: getMessage("readiness.material.empty"), + fix: { label: getMessage("preflight.inputs.chooseMaterial"), stepId: "material" }, }; } if (materials.length > 1) { return { id: "inputs", - label: "Material", + label: getMessage("readiness.material.label"), state: "pass", // The multiplier is the thing a reader most often does not expect at // submit time, so it is the thing the row says. - detail: `${materials.length} materials — the workflow runs ${materials.length} times`, + detail: getMessage("preflight.inputs.batch", { count: materials.length }), }; } @@ -77,11 +82,12 @@ export const checkInputs: PreflightCheck = async ({ return { id: "inputs", - label: "Material", + label: getMessage("readiness.material.label"), state: "pass", detail: - [summary.formula ?? summary.name ?? "1 material", atoms].filter(Boolean).join(" · ") || - "1 material", + [summary.formula ?? summary.name ?? getMessage("readiness.material.single"), atoms] + .filter(Boolean) + .join(" · ") || getMessage("readiness.material.single"), }; }; @@ -97,10 +103,10 @@ export const checkWorkflowRenders: PreflightCheck = async ({ job }) => { if (!subworkflows.length) { return { id: "workflow", - label: "Workflow renders", + label: getMessage("preflight.workflow.label"), state: "fail", - detail: "No workflow selected", - fix: { label: "Choose a workflow", stepId: "workflow" }, + detail: getMessage("readiness.workflow.empty"), + fix: { label: getMessage("blocker.workflow"), stepId: "workflow" }, }; } @@ -112,9 +118,9 @@ export const checkWorkflowRenders: PreflightCheck = async ({ job }) => { if (typeof job.render !== "function") { return { id: "workflow", - label: "Workflow renders", + label: getMessage("preflight.workflow.label"), state: "skip", - detail: `${unitCount} units — rendering not available here`, + detail: getMessage("preflight.workflow.noRenderer", { count: unitCount }), }; } @@ -123,19 +129,19 @@ export const checkWorkflowRenders: PreflightCheck = async ({ job }) => { } catch (error) { return { id: "workflow", - label: "Workflow renders", + label: getMessage("preflight.workflow.label"), state: "fail", - detail: "A unit's input template failed to render", + detail: getMessage("preflight.workflow.failed"), explanation: error instanceof Error ? error.message : String(error), - fix: { label: "Open the workflow", stepId: "workflow" }, + fix: { label: getMessage("preflight.workflow.open"), stepId: "workflow" }, }; } return { id: "workflow", - label: "Workflow renders", + label: getMessage("preflight.workflow.label"), state: "pass", - detail: `${unitCount} units · all input templates render`, + detail: getMessage("preflight.workflow.ok", { count: unitCount }), }; }; @@ -151,10 +157,10 @@ export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata if (!clusterName) { return { id: "compute", - label: "Compute within limits", + label: getMessage("preflight.compute.label"), state: "fail", - detail: "No cluster selected", - fix: { label: "Configure compute", stepId: "compute" }, + detail: getMessage("preflight.compute.noCluster"), + fix: { label: getMessage("blocker.compute"), stepId: "compute" }, }; } @@ -165,18 +171,28 @@ export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata if (!limits) { return { id: "compute", - label: "Compute within limits", + label: getMessage("preflight.compute.label"), state: "skip", - detail: `${clusterName} · ${resources} — no published limits to check against`, + detail: getMessage("preflight.compute.noLimits", { + cluster: clusterName, + resources, + }), }; } const violations: string[] = []; if (limits.maxNodes !== undefined && (compute?.nodes ?? 0) > limits.maxNodes) { - violations.push(`${compute.nodes} nodes exceeds the ${limits.maxNodes}-node limit`); + violations.push( + getMessage("preflight.compute.overNodes", { + nodes: compute.nodes, + limit: limits.maxNodes, + }), + ); } if (limits.maxPpn !== undefined && (compute?.ppn ?? 0) > limits.maxPpn) { - violations.push(`${compute.ppn} cores per node exceeds the limit of ${limits.maxPpn}`); + violations.push( + getMessage("preflight.compute.overPpn", { ppn: compute.ppn, limit: limits.maxPpn }), + ); } if ( limits.maxWalltimeHours !== undefined && @@ -184,20 +200,21 @@ export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata walltimeHours > limits.maxWalltimeHours ) { violations.push( - `walltime ${formatHours(walltimeHours)} exceeds the queue limit of ${formatHours( - limits.maxWalltimeHours, - )}`, + getMessage("preflight.compute.overWalltime", { + walltime: formatHours(walltimeHours), + limit: formatHours(limits.maxWalltimeHours), + }), ); } if (violations.length) { return { id: "compute", - label: "Compute within limits", + label: getMessage("preflight.compute.label"), state: "fail", detail: capitalise(violations[0]), explanation: violations.length > 1 ? violations.map(capitalise).join(". ") : undefined, - fix: { label: "Adjust compute", stepId: "compute" }, + fix: { label: getMessage("preflight.compute.adjust"), stepId: "compute" }, }; } @@ -205,7 +222,7 @@ export const checkComputeLimits: PreflightCheck = async ({ job, clusterMetadata return { id: "compute", - label: "Compute within limits", + label: getMessage("preflight.compute.label"), state: "pass", detail: [clusterName, resources, walltime].filter(Boolean).join(" · "), }; @@ -229,9 +246,9 @@ export const checkBudget: PreflightCheck = async ({ if (estimate.coreHours === undefined) { return { id: "budget", - label: "Budget", + label: getMessage("preflight.budget.label"), state: "skip", - detail: "Set nodes, cores and a walltime to estimate the cost", + detail: getMessage("preflight.budget.incomplete"), }; } @@ -244,16 +261,24 @@ export const checkBudget: PreflightCheck = async ({ const remaining = quota?.remainingCoreHours; if (remaining === undefined) { - return { id: "budget", label: "Budget", state: "skip", detail: usage }; + return { + id: "budget", + label: getMessage("preflight.budget.label"), + state: "skip", + detail: usage, + }; } if (estimate.coreHours > remaining) { return { id: "budget", - label: "Budget", + label: getMessage("preflight.budget.label"), state: "fail", - detail: `${usage} — only ${formatCoreHours(remaining)} left this month`, - fix: { label: "Reduce resources", stepId: "compute" }, + detail: getMessage("preflight.budget.overQuota", { + usage, + remaining: formatCoreHours(remaining) ?? "", + }), + fix: { label: getMessage("preflight.budget.reduce"), stepId: "compute" }, }; } @@ -263,18 +288,26 @@ export const checkBudget: PreflightCheck = async ({ if (estimate.coreHours > remaining / 2) { return { id: "budget", - label: "Budget", + label: getMessage("preflight.budget.label"), state: "warn", - detail: `${usage} — more than half of the remaining ${formatCoreHours(remaining)}`, - explanation: `${formatCoreHours(left)} would be left after this job.`, + detail: getMessage("preflight.budget.mostOfQuota", { + usage, + remaining: formatCoreHours(remaining) ?? "", + }), + explanation: getMessage("preflight.budget.wouldRemain", { + left: formatCoreHours(left) ?? "", + }), }; } return { id: "budget", - label: "Budget", + label: getMessage("preflight.budget.label"), state: "pass", - detail: `${usage} — ${formatCoreHours(left)} would remain`, + detail: getMessage("preflight.budget.remainingAfter", { + usage, + left: formatCoreHours(left) ?? "", + }), }; }; @@ -284,15 +317,20 @@ export const checkBudget: PreflightCheck = async ({ */ export const checkSaved: PreflightCheck = async ({ job }) => { if (job.id) { - return { id: "saved", label: "Saved", state: "pass", detail: "Job is saved" }; + return { + id: "saved", + label: getMessage("preflight.saved.label"), + state: "pass", + detail: getMessage("preflight.saved.ok"), + }; } return { id: "saved", - label: "Saved", + label: getMessage("preflight.saved.label"), state: "fail", - detail: "The job has never been saved", - fix: { label: "Save the job", stepId: "review" }, + detail: getMessage("preflight.saved.never"), + fix: { label: getMessage("blocker.save"), stepId: "review" }, }; }; diff --git a/src/preflight/runPreflightChecks.ts b/src/preflight/runPreflightChecks.ts index e072bcd..42983c6 100644 --- a/src/preflight/runPreflightChecks.ts +++ b/src/preflight/runPreflightChecks.ts @@ -1,3 +1,4 @@ +import { getMessage } from "../messages"; import { getInjectedDeps } from "../setDependencies"; import { DEFAULT_PREFLIGHT_CHECKS } from "./checks"; import type { PreflightCheck, PreflightContext, PreflightReport, PreflightRow } from "./types"; @@ -27,7 +28,7 @@ async function runOne( id: `check-error-${check.name || "anonymous"}`, label: check.name || "Check", state: "skip", - detail: "This check could not run", + detail: getMessage("preflight.checkFailed"), explanation: error instanceof Error ? error.message : String(error), }; } @@ -84,18 +85,22 @@ export function getReportSummary( report: PreflightReport | null, acknowledged: ReadonlySet | string[] = [], ): string { - if (!report) return "Running checks…"; + if (!report) return getMessage("preflight.running"); const acknowledgedIds = Array.isArray(acknowledged) ? new Set(acknowledged) : acknowledged; const failures = report.failures.length; - if (failures) return failures === 1 ? "1 problem to fix" : `${failures} problems to fix`; + if (failures) { + return failures === 1 + ? getMessage("preflight.oneProblem") + : getMessage("preflight.problems", { count: failures }); + } const unacknowledged = report.warnings.filter((id) => !acknowledgedIds.has(id)).length; if (unacknowledged) { return unacknowledged === 1 - ? "1 warning to acknowledge" - : `${unacknowledged} warnings to acknowledge`; + ? getMessage("preflight.oneWarning") + : getMessage("preflight.warnings", { count: unacknowledged }); } - return "All checks passed"; + return getMessage("preflight.allPassed"); } diff --git a/src/saveState.ts b/src/saveState.ts index 365ae31..f5f62ef 100644 --- a/src/saveState.ts +++ b/src/saveState.ts @@ -3,7 +3,7 @@ * * The designer saves manually, but says nothing about whether it needs to: a * job with unsaved edits looks exactly like a saved one, and closing the tab - * loses them silently. The guided-designer mockups show "All changes saved" in + * loses them silently. The guided-designer mockups show getMessage("saveState.saved") in * the header — copy that would be worse than the current silence if it were not * actually true, so this tracks the real thing. * @@ -12,6 +12,8 @@ * from being quiet about state it already knows. */ +import { getMessage } from "./messages"; + export type SaveState = "saved" | "unsaved" | "saving"; export function getSaveState({ @@ -27,9 +29,9 @@ export function getSaveState({ } const SAVE_STATE_LABELS: Record = { - saved: "All changes saved", - unsaved: "Unsaved changes", - saving: "Saving…", + saved: getMessage("saveState.saved"), + unsaved: getMessage("saveState.unsaved"), + saving: getMessage("saveState.saving"), }; export function getSaveStateLabel(state: SaveState): string { diff --git a/tests/messages.tests.ts b/tests/messages.tests.ts new file mode 100644 index 0000000..30baa02 --- /dev/null +++ b/tests/messages.tests.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import { getJobReadiness } from "../src/jobReadiness"; +import { getMessage, MESSAGES, type TranslateFunction } from "../src/messages"; +import { setDependencies } from "../src/setDependencies"; + +/** Restores English between tests; the resolver is module-level state. */ +const clearTranslator = () => setDependencies({ translate: undefined }); + +afterEach(clearTranslator); + +describe("getMessage — English fallback", () => { + it("returns the English default when no host resolver is injected", () => { + assert.equal(getMessage("readiness.review.ready"), "Ready to submit"); + }); + + it("interpolates named parameters", () => { + assert.equal( + getMessage("readiness.material.batch", { count: 3 }), + "3 materials — runs 3 times", + ); + }); + + it("fills a parameter used more than once in the sentence", () => { + // Languages place the count differently; the message stays one string so a + // translation can move both occurrences. + assert.match(getMessage("preflight.inputs.batch", { count: 4 }), /4 materials.*4 times/); + }); + + it("leaves an unfilled placeholder visible rather than blanking it", () => { + assert.equal(getMessage("readiness.material.fromParent"), "From parent job {name}"); + }); +}); + +describe("getMessage — host resolver", () => { + it("prefers the host's translation", () => { + setDependencies({ translate: ((key) => `«${key}»`) as TranslateFunction }); + assert.equal(getMessage("readiness.review.ready"), "«readiness.review.ready»"); + }); + + it("interpolates into the host's translation too", () => { + setDependencies({ + translate: ((key) => + key === "readiness.material.batch" + ? "{count} Materialien" + : undefined) as TranslateFunction, + }); + assert.equal(getMessage("readiness.material.batch", { count: 2 }), "2 Materialien"); + }); + + it("falls back per key, not per host", () => { + // A partial translation is the normal case; the keys it lacks must still + // read as sentences. + setDependencies({ + translate: ((key) => + key === "readiness.review.ready" ? "Bereit" : undefined) as TranslateFunction, + }); + assert.equal(getMessage("readiness.review.ready"), "Bereit"); + assert.equal(getMessage("readiness.review.viewOnly"), "View only"); + }); + + it("never shows a key when a translation is empty", () => { + setDependencies({ translate: (() => "") as TranslateFunction }); + assert.equal(getMessage("preflight.allPassed"), "All checks passed"); + }); + + it("survives a resolver that throws", () => { + // A broken host translator must not take the designer's copy with it. + setDependencies({ + translate: (() => { + throw new Error("i18n not initialised"); + }) as TranslateFunction, + }); + assert.equal(getMessage("preflight.allPassed"), "All checks passed"); + }); +}); + +describe("the catalogue is what the designer actually says", () => { + it("routes readiness summaries through it", () => { + setDependencies({ translate: ((key) => `«${key}»`) as TranslateFunction }); + const readiness = getJobReadiness({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }); + assert.deepEqual( + readiness.steps.map((step) => step.summary), + [ + "«readiness.material.empty»", + "«readiness.workflow.empty»", + "«readiness.compute.empty»", + "«readiness.review.remaining»", + ], + ); + }); + + it("routes the submit blockers through it", () => { + setDependencies({ translate: ((key) => `«${key}»`) as TranslateFunction }); + const readiness = getJobReadiness({ + job: { isInInitialStatus: true, workflow: { subworkflows: [] }, compute: null }, + materials: [], + }); + assert.deepEqual(readiness.blockingReasons, [ + "«blocker.material»", + "«blocker.workflow»", + "«blocker.compute»", + "«blocker.save»", + ]); + }); + + it("has no duplicate English defaults hiding a merge mistake", () => { + // Two keys with identical copy is usually a copy-paste, and it makes the + // catalogue lie about how many distinct things the designer says. + const seen = new Map(); + const duplicates: string[] = []; + Object.entries(MESSAGES).forEach(([key, text]) => { + const previous = seen.get(text); + if (previous) duplicates.push(`${previous} / ${key}: "${text}"`); + else seen.set(text, key); + }); + assert.deepEqual(duplicates, []); + }); + + it("names every parameter its sentence uses", () => { + // A key whose default has no placeholders but whose callers pass params + // silently drops them; the reverse shows braces to the reader. + const withPlaceholders = Object.entries(MESSAGES).filter(([, text]) => + /\{\w+\}/.test(text), + ); + assert.ok(withPlaceholders.length > 10, "expected interpolated messages in the catalogue"); + withPlaceholders.forEach(([key, text]) => { + assert.doesNotMatch(text, /\{\s*\}/, `${key} has an empty placeholder`); + }); + }); +}); diff --git a/tests/renderGuardrail.tests.ts b/tests/renderGuardrail.tests.ts new file mode 100644 index 0000000..b55cf40 --- /dev/null +++ b/tests/renderGuardrail.tests.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { estimateComputeUsage, formatEstimate } from "../src/computeEstimate"; +import { getJobReadiness } from "../src/jobReadiness"; +import { getSubmitBlockers } from "../src/jobSubmission"; +import { checkBudget, checkComputeLimits, checkInputs, runPreflightChecks } from "../src/preflight"; + +/** + * `job.render()` renders every unit's input templates. It is the single most + * expensive thing the designer does, and `Job.jsx`'s update machinery — + * `shouldComponentUpdate` mixins, `renderGeneration`, `persistJob()` — is built + * around calling it as rarely as possible. + * + * The guided designer recomputes readiness, the estimate and the submit blockers + * on every render, which means on every keystroke in the compute form. If any of + * those touched the entity, typing a walltime would re-render the workflow. + * These tests count the calls, so that regression cannot land quietly. + * + * The one deliberate exception is the preflight's workflow check: rendering is + * how template errors surface at all, and submission would do it a moment later + * anyway. It must render exactly once — not once per check. + */ + +/** A job that counts what is asked of it. */ +function makeSpyJob() { + const calls: string[] = []; + + return { + calls, + job: { + id: "job-1", + isInInitialStatus: true, + workflow: { name: "MD", subworkflows: [{ units: [{}, {}] }] }, + compute: { + cluster: { fqdn: "cluster-1.mat3ra.com" }, + nodes: 1, + ppn: 16, + timeLimit: "04:00:00", + }, + render: () => { + calls.push("render"); + }, + setCompute: () => { + calls.push("setCompute"); + }, + setWorkflow: () => { + calls.push("setWorkflow"); + }, + setMaterial: () => { + calls.push("setMaterial"); + }, + }, + }; +} + +const clusterMetadata = [ + { + fqdn: "cluster-1.mat3ra.com", + pricePerCoreHour: 0.08, + currency: "USD", + limits: { maxNodes: 4, maxPpn: 32, maxWalltimeHours: 12 }, + }, +]; + +describe("a compute keystroke must not re-render the workflow", () => { + it("readiness derivation never touches the entity", () => { + const { job, calls } = makeSpyJob(); + getJobReadiness({ job: job as never, materials: [{}], clusterMetadata }); + assert.deepEqual(calls, []); + }); + + it("the estimate never touches the entity", () => { + const { job, calls } = makeSpyJob(); + formatEstimate(estimateComputeUsage(job.compute, clusterMetadata, 3)); + assert.deepEqual(calls, []); + }); + + it("the submit blockers never touch the entity", () => { + const { job, calls } = makeSpyJob(); + getSubmitBlockers({ job: job as never, materials: [{}] }); + assert.deepEqual(calls, []); + }); + + it("stays silent across a burst of edits, not just one", () => { + // What a reader actually does: type into a walltime field. Each keystroke + // re-renders Job.jsx, which recomputes all three of the above. + const { job, calls } = makeSpyJob(); + ["0", "04", "04:", "04:0", "04:00", "04:00:0", "04:00:00"].forEach((timeLimit) => { + const edited = { ...job, compute: { ...job.compute, timeLimit } }; + getJobReadiness({ job: edited as never, materials: [{}], clusterMetadata }); + estimateComputeUsage(edited.compute, clusterMetadata); + getSubmitBlockers({ job: edited as never, materials: [{}] }); + }); + assert.deepEqual(calls, []); + }); +}); + +describe("the preflight renders once, deliberately", () => { + it("renders exactly once for a whole report", async () => { + const { job, calls } = makeSpyJob(); + await runPreflightChecks({ + job: job as never, + materials: [{}], + clusterMetadata, + quota: { remainingCoreHours: 500, totalCoreHours: 1000 }, + }); + assert.deepEqual(calls, ["render"]); + }); + + it("no check other than the workflow one renders", async () => { + const { job, calls } = makeSpyJob(); + const context = { + job: job as never, + materials: [{}], + clusterMetadata, + quota: { remainingCoreHours: 500 }, + }; + await checkInputs(context); + await checkComputeLimits(context); + await checkBudget(context); + assert.deepEqual(calls, []); + }); + + it("does not render again when the reader re-runs the checks", async () => { + // Two runs, two renders — one per run, never two per run. + const { job, calls } = makeSpyJob(); + const context = { job: job as never, materials: [{}], clusterMetadata }; + await runPreflightChecks(context); + await runPreflightChecks(context); + assert.deepEqual(calls, ["render", "render"]); + }); +}); From d0a54b79a501f6dc46764e9a725d9ec07574ae0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:10:21 +0000 Subject: [PATCH 26/33] test: e2e coverage for the guided flow, replacing a suite for an app that is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suite tested a placeholder: `#job-designer-app`, an `h1`, a `#status-badges-container` and four status badges. None of them exist in the standalone demo — 4 of its 5 scenarios could only fail. Its baseUrl also pointed at port 3002, which is workflow-designer's dev server, so the suite was aimed at a different application entirely. Replaced with the coverage the plan's testing section asks for: the create-job path through the rail, a disabled Submit naming the first thing missing, context-strip navigation, an all-pass preflight, a configuration over the cluster's limits blocked before the preflight, an over-quota job failing it with the fix deep-linking to Compute, a warning holding Submit until acknowledged, the materials tray, the run monitor, and the legacy layout being untouched. Selectors are ids the components set deliberately, never text or classes, so a copy change — or a translation, now that the strings go through `src/messages.ts` — fails the assertion it should rather than the lookup. Cypress is not installed in this environment, so I could not execute the run. Every one of the 30 selectors was instead resolved against the running demo with a headless browser, including the submit tooltip's aria-label reading "Configure compute" and all five preflight rows: 0 failures. The step logic is exercised, but the Cypress harness itself is unverified here and wants a run before anyone relies on it. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- tests/e2e/cypress.config.ts | 4 +- tests/e2e/cypress/e2e/guided_designer.feature | 85 +++++++++ .../cypress/e2e/job_designer_loads.feature | 27 --- .../step_definitions/guided_designer.steps.ts | 180 ++++++++++++++++++ .../step_definitions/job_designer.steps.ts | 53 ------ 5 files changed, 268 insertions(+), 81 deletions(-) create mode 100644 tests/e2e/cypress/e2e/guided_designer.feature delete mode 100644 tests/e2e/cypress/e2e/job_designer_loads.feature create mode 100644 tests/e2e/cypress/e2e/step_definitions/guided_designer.steps.ts delete mode 100644 tests/e2e/cypress/e2e/step_definitions/job_designer.steps.ts diff --git a/tests/e2e/cypress.config.ts b/tests/e2e/cypress.config.ts index 2b6192b..75ee6ae 100644 --- a/tests/e2e/cypress.config.ts +++ b/tests/e2e/cypress.config.ts @@ -4,7 +4,9 @@ import createEsbuildPlugin from "@badeball/cypress-cucumber-preprocessor/esbuild import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; import { defineConfig } from "cypress"; -const defaultBaseUrl = "http://localhost:3002"; +// The job designer's own dev server. This said 3002, which is +// `workflow-designer`'s port — the suite was pointed at a different app. +const defaultBaseUrl = "http://localhost:3003"; export default defineConfig({ e2e: { diff --git a/tests/e2e/cypress/e2e/guided_designer.feature b/tests/e2e/cypress/e2e/guided_designer.feature new file mode 100644 index 0000000..4aa1dd9 --- /dev/null +++ b/tests/e2e/cypress/e2e/guided_designer.feature @@ -0,0 +1,85 @@ +Feature: The guided designer takes a job from draft to running + + The flow SOF-8023 introduced: a rail that says what a job still needs, a + context strip that keeps the other choices visible, a preflight that refuses + to submit something broken, and a monitor once it runs. + + Background: + Given I open the guided job designer + + Scenario: The rail says what the job still needs + Then the readiness rail is visible + And the rail step "material" reads "Si" + And the rail step "compute" reads "Cluster and resources needed" + And the rail step "review" reads "1 step remaining" + And the submit button is disabled + + Scenario: A disabled Submit names the first thing missing + Then the submit button explains "Configure compute" + + Scenario: The context strip keeps the other choices visible and navigates + Then the context strip is visible + And the context chip "workflow" is visible + When I click the context chip "compute" + Then the rail step "compute" is the current step + + Scenario: Choosing a cluster completes the compute step + When I configure compute on "cluster-007" with 1 nodes and 16 cores for "04:00:00" + Then the rail step "compute" reads "cluster-007.exabyte.io · 1×16 · 04:00:00" + And the rail step "review" reads "Ready to submit" + And the estimate chip reads "64 core·h ≈ $5.12" + And the submit button is enabled + + Scenario: Submit runs a preflight, and every check passes on a ready job + When I configure compute on "cluster-007" with 1 nodes and 16 cores for "04:00:00" + And I click submit + Then the preflight dialog is open + And the preflight summary reads "All checks passed" + And the preflight row "inputs" passed + And the preflight row "workflow" passed + And the preflight row "compute" passed + And the preflight row "saved" passed + And the preflight submit button is enabled + + Scenario: A configuration over the cluster's limits never reaches the preflight + When I configure compute on "cluster-007" with 1 nodes and 16 cores for "24:00:00" + Then the rail step "compute" reads "over the 12 h queue limit" + And the submit button is disabled + And the submit button explains "Bring compute within the cluster's limits" + + Scenario: A job within limits but over quota fails the preflight, and the fix deep-links + When I configure compute on "cluster-007" with 4 nodes and 32 cores for "12:00:00" + And I click submit + Then the preflight summary reads "1 problem to fix" + And the preflight row "budget" failed + And the preflight submit button is disabled + When I follow the preflight fix for "budget" + Then the preflight dialog is closed + And the rail step "compute" is the current step + + Scenario: A warning holds Submit until it is acknowledged + When I configure compute on "cluster-007" with 4 nodes and 32 cores for "03:00:00" + And I click submit + Then the preflight summary reads "1 warning to acknowledge" + And the preflight submit button is disabled + When I acknowledge the preflight warning "budget" + Then the preflight summary reads "All checks passed" + And the preflight submit button is enabled + + Scenario: The materials tray states the batch consequence + When I open the rail step "material" + Then the materials tray is visible + And the batch note is visible + + Scenario: A running job shows the monitor instead of the submit flow + When I switch the demo job to running + Then the rail step "results" reads "Running" + And the submit button is absent + When I open the rail step "results" + Then the run monitor is visible + And the run monitor lists the workflow's units + + Scenario: The legacy layout is untouched + When I turn the guided layout off + Then the readiness rail is absent + And the numbered tab strip is visible diff --git a/tests/e2e/cypress/e2e/job_designer_loads.feature b/tests/e2e/cypress/e2e/job_designer_loads.feature deleted file mode 100644 index b431860..0000000 --- a/tests/e2e/cypress/e2e/job_designer_loads.feature +++ /dev/null @@ -1,27 +0,0 @@ -Feature: Job Designer standalone app loads and renders - - Scenario: The standalone app root element renders without errors - When I open the job designer app - Then I see the job designer root element - - Scenario: The app title is correct - When I open the job designer app - Then the page title contains "Job Designer" - - Scenario: The job designer heading is visible - When I open the job designer app - Then I see the job designer heading - - Scenario: All known job statuses are rendered as badges - When I open the job designer app - Then the status badges container exists - And the pre_submission status badge is visible - And the active status badge is visible - And the finished status badge is visible - And the error status badge is visible - - Scenario: Job status color classes are applied correctly - When I open the job designer app - Then the active status badge has the warning color class - And the finished status badge has the success color class - And the error status badge has the error color class diff --git a/tests/e2e/cypress/e2e/step_definitions/guided_designer.steps.ts b/tests/e2e/cypress/e2e/step_definitions/guided_designer.steps.ts new file mode 100644 index 0000000..1f529bd --- /dev/null +++ b/tests/e2e/cypress/e2e/step_definitions/guided_designer.steps.ts @@ -0,0 +1,180 @@ +import { And, Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +/** + * Steps for `guided_designer.feature`. + * + * Every selector here is an `id` the components set deliberately + * (`#job-readiness-rail`, `#job-step-`, `#job-preflight-row-`) rather + * than a class or a text match. Text is asserted separately, so a copy change — + * or a translation, now that the strings go through `src/messages.ts` — fails + * the assertion it should and not the lookup. + */ + +/** The standalone demo mounts asynchronously; the rail is the first thing to settle. */ +const RENDER_TIMEOUT = 30_000; + +Given("I open the guided job designer", () => { + cy.visit("/"); + cy.get("#job-readiness-rail", { timeout: RENDER_TIMEOUT }).should("exist"); +}); + +// ─── Rail ───────────────────────────────────────────────────────────────────── + +Then("the readiness rail is visible", () => { + cy.get("#job-readiness-rail").should("be.visible"); +}); + +Then("the readiness rail is absent", () => { + cy.get("#job-readiness-rail").should("not.exist"); +}); + +Then("the rail step {string} reads {string}", (stepId: string, text: string) => { + cy.get(`#job-step-${stepId}`).should("contain.text", text); +}); + +Then("the rail step {string} is the current step", (stepId: string) => { + cy.get(`#job-step-${stepId}`).should("have.attr", "aria-current", "step"); +}); + +When("I open the rail step {string}", (stepId: string) => { + cy.get(`#job-step-${stepId}`).click(); +}); + +Then("the numbered tab strip is visible", () => { + cy.get(".MuiTabs-root").should("be.visible"); +}); + +// ─── Context strip ──────────────────────────────────────────────────────────── + +Then("the context strip is visible", () => { + cy.get("#job-context-strip").should("be.visible"); +}); + +Then("the context chip {string} is visible", (stepId: string) => { + cy.get(`#job-context-${stepId}`).should("be.visible"); +}); + +When("I click the context chip {string}", (stepId: string) => { + cy.get(`#job-context-${stepId}`).click(); +}); + +Then("the estimate chip reads {string}", (text: string) => { + cy.get("#job-context-estimate").should("contain.text", text); +}); + +// ─── Submit ─────────────────────────────────────────────────────────────────── + +Then("the submit button is disabled", () => { + cy.get("#job-submit-button").should("be.disabled"); +}); + +Then("the submit button is enabled", () => { + cy.get("#job-submit-button").should("not.be.disabled"); +}); + +Then("the submit button is absent", () => { + cy.get("#job-submit-button").should("not.exist"); +}); + +Then("the submit button explains {string}", (reason: string) => { + // The reason lives on the tooltip wrapper, since MUI needs a non-disabled + // element for a tooltip on a disabled control to fire at all. + cy.get("#job-submit-button").parent().should("have.attr", "aria-label", reason); +}); + +When("I click submit", () => { + cy.get("#job-submit-button").click(); +}); + +// ─── Compute ────────────────────────────────────────────────────────────────── + +When( + "I configure compute on {string} with {int} nodes and {int} cores for {string}", + (cluster: string, nodes: number, cores: number, walltime: string) => { + cy.get("#job-step-compute").click(); + cy.get("#root_cluster\\.fqdn").click(); + cy.get("li[role='option']").contains(cluster).click(); + + // A queue belongs to a cluster, so it is chosen after one is. + cy.get("#root_queue").click(); + cy.get("table tr").contains("OR").click(); + cy.get("body").type("{esc}"); + + cy.get("#root_nodes").clear().type(String(nodes)); + cy.get("#root_ppn").clear().type(String(cores)); + cy.get("#root_timeLimit").clear().type(walltime).blur(); + }, +); + +// ─── Preflight ──────────────────────────────────────────────────────────────── + +Then("the preflight dialog is open", () => { + cy.get("#job-preflight-dialog").should("be.visible"); +}); + +Then("the preflight dialog is closed", () => { + cy.get("#job-preflight-dialog").should("not.exist"); +}); + +Then("the preflight summary reads {string}", (text: string) => { + cy.get("#job-preflight-summary").should("contain.text", text); +}); + +Then("the preflight row {string} passed", (rowId: string) => { + cy.get(`#job-preflight-row-${rowId}`).should("have.attr", "data-state", "pass"); +}); + +Then("the preflight row {string} failed", (rowId: string) => { + cy.get(`#job-preflight-row-${rowId}`).should("have.attr", "data-state", "fail"); +}); + +Then("the preflight submit button is enabled", () => { + cy.get("#job-preflight-submit").should("not.be.disabled"); +}); + +Then("the preflight submit button is disabled", () => { + cy.get("#job-preflight-submit").should("be.disabled"); +}); + +When("I follow the preflight fix for {string}", (rowId: string) => { + cy.get(`#job-preflight-fix-${rowId}`).click(); +}); + +When("I acknowledge the preflight warning {string}", (rowId: string) => { + cy.get(`#job-preflight-ack-${rowId}`).click(); +}); + +// ─── Materials ──────────────────────────────────────────────────────────────── + +Then("the materials tray is visible", () => { + cy.get("#job-materials-tray").should("be.visible"); +}); + +Then("the batch note is visible", () => { + cy.get("#job-materials-batch-note").should("be.visible"); +}); + +// ─── Monitor ────────────────────────────────────────────────────────────────── + +When("I switch the demo job to running", () => { + cy.get("[data-tid='simulate-run-toggle']").click(); + // The demo remounts the designer, so wait for the rail to come back rather + // than asserting against the tree that is being torn down. + cy.get("#job-step-results", { timeout: RENDER_TIMEOUT }).should("exist"); +}); + +Then("the run monitor is visible", () => { + cy.get("#run-monitor", { timeout: RENDER_TIMEOUT }).should("be.visible"); +}); + +Then("the run monitor lists the workflow's units", () => { + cy.get("[id^='run-monitor-unit-']").should("have.length.greaterThan", 0); +}); + +// ─── Demo controls ──────────────────────────────────────────────────────────── + +When("I turn the guided layout off", () => { + cy.contains("button", "Guided layout").click(); +}); + +export { And }; diff --git a/tests/e2e/cypress/e2e/step_definitions/job_designer.steps.ts b/tests/e2e/cypress/e2e/step_definitions/job_designer.steps.ts deleted file mode 100644 index 047fa18..0000000 --- a/tests/e2e/cypress/e2e/step_definitions/job_designer.steps.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { And, Then, When } from "@badeball/cypress-cucumber-preprocessor"; - -When("I open the job designer app", () => { - cy.visit("/"); -}); - -Then("I see the job designer root element", () => { - cy.get("#root").should("exist"); - cy.get("#root").should("not.be.empty"); -}); - -Then("the page title contains {string}", (title: string) => { - cy.title().should("contain", title); -}); - -Then("I see the job designer heading", () => { - cy.get("#job-designer-app").should("exist"); - cy.get("h1").should("contain.text", "Job Designer"); -}); - -// ─── Status Badges ──────────────────────────────────────────────────────────── - -Then("the status badges container exists", () => { - cy.get("#status-badges-container").should("exist"); -}); - -Then("the pre_submission status badge is visible", () => { - cy.get("[data-status='pre_submission']").should("be.visible"); -}); - -Then("the active status badge is visible", () => { - cy.get("[data-status='active']").should("be.visible"); -}); - -Then("the finished status badge is visible", () => { - cy.get("[data-status='finished']").should("be.visible"); -}); - -Then("the error status badge is visible", () => { - cy.get("[data-status='error']").should("be.visible"); -}); - -Then("the active status badge has the warning color class", () => { - cy.get("[data-status='active']").should("have.class", "status-badge--warning"); -}); - -Then("the finished status badge has the success color class", () => { - cy.get("[data-status='finished']").should("have.class", "status-badge--success"); -}); - -Then("the error status badge has the error color class", () => { - cy.get("[data-status='error']").should("have.class", "status-badge--error"); -}); From acddec38030410d2c312eb583336552e265be88c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:10:55 +0000 Subject: [PATCH 27/33] docs: record the cross-cutting items as built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four the phases had left: the render-count guardrail, the message catalogue, the analytics events and the e2e suite — plus the ButtonMultiSelect regression the risk table asked for. Each entry says what is genuinely done and what is not: component chrome outside the phase 2–3 surfaces is still hard-coded English, nothing is collecting the analytics events yet, and the Cypress run is unverified in this environment even though every selector was checked against the live demo. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- ...08-16-Job-Designer-Guided-Designer-Plan.md | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index 7946eab..ff8b529 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -181,7 +181,9 @@ are recorded in workflow-tab entry. Readiness and estimate recomputation must be memoized pure derivation that never calls `persistJob` or bumps `renderGeneration`; a compute keystroke must not trigger a workflow re-render. Add a regression test that counts `job.render()` calls during - compute edits. + compute edits. **Done** — `tests/renderGuardrail.tests.ts` counts the calls: + readiness, the estimate and the submit blockers stay at zero across a burst of + walltime keystrokes, and a whole preflight report renders exactly once. - **Accessibility:** rail and dialogs are keyboard-operable (focus trap in `PreflightDialog`, `aria-current` on the active step, visible focus states); status is never conveyed by color alone (icons + labels on every `StatusChip`); the palette work in 1.5 fixes the contrast @@ -189,7 +191,12 @@ are recorded in - **Localization:** the webapp localizes via TAPi18n (stubbed as `createMessageTextTAPi18n` in `Job.jsx`); new user-facing strings (readiness summaries, preflight messages, save-state copy) go through an injectable message resolver on the seam with English fallbacks — no - hard-coded strings scattered through components. + hard-coded strings scattered through components. **Done** — `src/messages.ts` holds ~70 + keys with English defaults and named interpolation; hosts inject + `setDependencies({ translate })`, and fallback is per key so a partial translation still + reads as sentences. Component *chrome* outside the phase 2–3 surfaces (legacy tab labels, + the error card, dropdown actions) is not migrated — those are static strings a scanner can + lift, where the derived prose here could not have been. - **Shared with the Materials Designer effort:** the cove primitives and palette repairs in 1.5 serve the parallel materials-designer UX update too — keep primitive APIs generic (no job-specific props) and land cove work first so both designers consume the same release. @@ -215,7 +222,12 @@ Additive props with safe defaults everywhere, so no lockstep release is required ## Success metrics Instrument before flipping the `useGuidedDesigner` flag so there is a baseline. Events go -through an analytics hook injected via the seam (no-op in standalone): +through an analytics hook injected via the seam (no-op in standalone). + +**Built** — `src/analytics.ts` declares the event set and the designer emits it; a host +supplies `setDependencies({ trackEvent })`. A recorder that throws is swallowed: analytics +must never be what stops somebody submitting a job. What is still needed is the webapp's +recorder and a dashboard — the events exist, nothing is collecting them yet. - **Time to first submit** for a new job (open designer → successful submit) — the headline number the redesign should move. @@ -233,7 +245,10 @@ through an analytics hook injected via the seam (no-op in standalone): `runPreflightChecks`, preset application, touched-state validation reducer. - Component/e2e (Cypress, `tests/e2e`): create-job happy path through the rail; submit blocked by a failing preflight then fixed; context-strip navigation; multi-material tray; monitor - simulation smoke test. + simulation smoke test. **Written** — `guided_designer.feature` covers all of it. The suite + it replaces tested a placeholder app that no longer exists and pointed at + workflow-designer's port. Cypress is not installed in the authoring environment, so the run + itself is unverified; all 30 selectors were resolved against the live demo instead. - Regression: webapp integration run (`web-app` Cypress UI suite) before flipping the flag, since the webapp injects its own header/dialogs through the seam this plan touches. @@ -242,7 +257,7 @@ through an analytics hook injected via the seam (no-op in standalone): | Risk | Mitigation | |------|------------| | Cluster pricing/limits/queue data may not exist as a clean API | Estimate panel degrades: hide cost/ETA rows when metadata is absent; limits fall back to none | -| `ButtonMultiSelect` mount-snapshot bug class (stale closures in header buttons) | Read `this.state.entity` at click time (pattern already documented in `Job.jsx`); add a regression test | +| `ButtonMultiSelect` mount-snapshot bug class (stale closures in header buttons) | **Done** — fixed in cove (tracks the selected *id*, resolves against the live prop) and pinned by `cove/tests/selectedOption.tests.ts`. `Job.jsx`'s read-at-click-time workaround can come out once cove ships | | Rail layout breaks webapp deep links (`getRouteQueryTab`) | Keep tab ids stable; rail maps ids 1:1 to today's `TAB_NAVIGATION_CONFIG` | | Cross-repo sequencing (ive / workflow-designer / cove / jove versions) | Land package changes behind additive props with safe defaults; bump versions in job-designer last | | Dataset-driven jobs (`isUsingDataset`) diverge from the material path | Rail renders a Dataset step in place of Material; readiness selector covers both branches | From 2e884fd0027b8ac636334e29fca6679e9aa05193 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:55:34 +0000 Subject: [PATCH 28/33] fix: make the rail a creation path, and collapse it on narrow screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing 2.1's acceptance criteria against the running app found two things I had recorded as built that were not. **"A new user can create and submit a job without opening any dropdown."** They could not. The plan says the "Select …" dialogs open from Change affordances on their steps; the rail only navigated, so picking a material or a workflow still meant the actions dropdown — the exact thing the rail exists to replace. Each step that owns a dialog now carries a Change (or Choose, when the step is empty) button, labelled per step so a screen reader hears "Change Material" rather than four identical "Change"s. It sits beside the step row rather than inside it: a button within a button is invalid and unreachable by keyboard. **"Collapses to a horizontal stepper under 760 px."** It did not — the rail went full-width with the steps still stacked, pushing the step's own content off a narrow screen. Now a horizontal scrolling strip below the md breakpoint. Measured: vertical at 900 px and above, horizontal at 700 and 500, with the rail 263 px tall before and 77 px after. **And a crash the affordances exposed.** Clicking Change threw `object is not iterable`. This package's types disagree with its own code: `JobDesignerDialogState` describes `{ isOpen, open, close }`, which the container declares and the demo passed, while `Job.jsx` destructures `[open, close]`, which `useReduxDialog` returns and the webapp therefore flows. Both shapes are real. Nothing caught it because the only route to those openers was the dropdown, which no test or demo run ever clicked. `normalizeDialogHandle` accepts either rather than picking a winner, so no host has to migrate before the rail ships, and a dialog the host never wired does nothing instead of throwing. The demo now passes tuples as the webapp does, and says which dialog would have opened rather than silently doing nothing — otherwise broken wiring looks exactly like working wiring. Verified: both Change buttons fire their openers, keyboard navigation and aria-current are unaffected, and Change is correctly absent from Compute (edited in place) and Review (nothing to choose). 111 tests. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/components/Job.d.ts | 17 +++ dist/components/Job.d.ts.map | 2 +- dist/components/Job.js | 34 ++++-- dist/components/JobReadinessRail.d.ts | 13 ++- dist/components/JobReadinessRail.d.ts.map | 2 +- dist/components/JobReadinessRail.js | 52 ++++++--- dist/dialogHandles.d.ts | 34 ++++++ dist/dialogHandles.d.ts.map | 1 + dist/dialogHandles.js | 33 ++++++ dist/messages.d.ts | 2 + dist/messages.d.ts.map | 2 +- dist/messages.js | 3 + dist/standalone/index.js | 45 ++++---- src/components/Job.jsx | 49 ++++++-- src/components/JobReadinessRail.tsx | 133 +++++++++++++++------- src/dialogHandles.ts | 52 +++++++++ src/messages.ts | 4 + src/standalone/index.tsx | 49 ++++---- tests/dialogHandles.tests.ts | 54 +++++++++ 19 files changed, 460 insertions(+), 121 deletions(-) create mode 100644 dist/dialogHandles.d.ts create mode 100644 dist/dialogHandles.d.ts.map create mode 100644 dist/dialogHandles.js create mode 100644 src/dialogHandles.ts create mode 100644 tests/dialogHandles.tests.ts diff --git a/dist/components/Job.d.ts b/dist/components/Job.d.ts index bea76a9..404cf41 100644 --- a/dist/components/Job.d.ts +++ b/dist/components/Job.d.ts @@ -39,6 +39,23 @@ declare class Job { * about. */ get jobReadiness(): import("../jobReadiness").JobReadiness; + /** + * The "Select …" dialog that fills each step. + * + * This is what makes the rail a creation path rather than navigation: without + * it the only way to choose a material or a workflow is still the actions + * dropdown, which is the thing the rail exists to replace. Review has nothing + * to choose, so it gets no affordance. + */ + get readinessStepDialogs(): { + material?: undefined; + dataset?: undefined; + workflow?: undefined; + } | { + material: () => void; + dataset: () => void; + workflow: () => void; + }; /** The rail's Review step has no tab of its own; it lands on Compute. */ onReadinessStepSelect: (stepId: any) => void; /** diff --git a/dist/components/Job.d.ts.map b/dist/components/Job.d.ts.map index 5272747..3e51af3 100644 --- a/dist/components/Job.d.ts.map +++ b/dist/components/Job.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AAyIA;IAMI,wBAeC;IAbG,WASC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAaC;IANO,mBAA4B;IAQpC;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAI7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mEAAmE;IACnE,yBAMC;IAED,mCAQE;IAHE,sBAA+B;IAKnC;;;;MAMC;IAED,yCAmBC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAQE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAIC;IAED,qCAQE;IAEF,sCAIE;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BA4UC;CACJ;kBAzqCiB,OAAO"} \ No newline at end of file +{"version":3,"file":"Job.d.ts","sourceRoot":"","sources":["../../src/components/Job.jsx"],"names":[],"mappings":";AA0IA;IAMI,wBAeC;IAbG,WASC;IACD,oBAAyC;IAY7C;;OAEG;IACH,sCAKC;IAfD;;OAEG;IACH,mBAEC;IAYD;;;;;OAKG;IACH,2BAEC;IAED,0BAEC;IAMD,6BAEC;IAED,2BAOC;IAMD,mCAGC;IAED,sBAcC;IAED,sCAEC;IAED,0BAaC;IANO,mBAA4B;IAQpC;;;OAGG;IACH,kCAAmC,UAAK,YAMtC;IAEF;;;;;;OAMG;IACH,2DASC;IAED;;;;;;;OAOG;IACH;;;;;;;;MAQC;IAED,yEAAyE;IACzE,wBAAyB,WAAM,UAI7B;IAEF;;;;;OAKG;IACH,4BAOC;IAED,yBAA+D;IAE/D,0BAAiE;IAEjE;;;;OAIG;IACH;;;;;;MASG;IAEH,mEAAmE;IACnE,yBAMC;IAED,mCAQE;IAHE,sBAA+B;IAKnC;;;;MAMC;IAED,yCAmBC;IAED,6BAGC;IAED,2DAaC;IAED,kBAAmB,YAAO,UAKxB;IAEF,sBAAuB,GAAG,WAAI,SAUf;IAEf,eAAgB,SAAI,UAKlB;IAEF,eAAgB,WAAM,UAKpB;IAEF,2BAME;IAEF,qCAmBC;IAED;;;;;;;;;;;;SA2CE;IAEF;;;;;;;OAOG;IACH,yCAKC;IAED,qCAAuF;IAEvF,sCAAyF;IAEzF,6BAQE;IAEF;;;;;;OAMG;IACH;;;;;;;OAOG;IACH,6CAUC;IAED;;;OAGG;IACH,8CAeC;IAED,wCAgDC;IAED,2CAYC;IAED,iDAuBC;IAED;;;;;;;;;;MA4BC;IAED;;;;;;;;;;;;;;;;;MAiBC;IAED,0BAAiC,QAAG,mBAuBlC;IAEF,0BAAiC,QAAG,mBAgBlC;IAEF,yBAA0B,cAAS,EAAE,iBAAY,UAU/C;IAEF,mCAeE;IAEF,sCAcE;IAEF,sCAQE;IAEF,mCAMC;IAED,qCAQE;IAEF,sCAME;IAEF,qCAsBE;IAEF,gBAAiB,YAAO,UAOtB;IAEF;;;MAGE;IAEF;;;MAGE;IAEF,4BA8UC;CACJ;kBAlsCiB,OAAO"} \ No newline at end of file diff --git a/dist/components/Job.js b/dist/components/Job.js index 0685a13..17e8df2 100644 --- a/dist/components/Job.js +++ b/dist/components/Job.js @@ -22,6 +22,7 @@ import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { ANALYTICS_EVENTS, durationSince, trackEvent } from "../analytics"; import { estimateComputeUsage, formatEstimate } from "../computeEstimate"; import { formatBlockedReason } from "../jobSubmission"; +import { normalizeDialogHandle } from "../dialogHandles"; import { getJobReadiness } from "../jobReadiness"; import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; @@ -294,7 +295,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin this.setCurrentTab(TAB_NAVIGATION_CONFIG.material.id); }; this.openAddMaterialsDialog = () => { - const [openAddMaterialsDialog, closeAddMaterialsDialog] = this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openAddMaterialsDialog, close: closeAddMaterialsDialog } = normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openAddMaterialsDialog({ id: "material-add", title: "Import materials", @@ -308,7 +309,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin }); }; this.openSelectMaterialsDialog = () => { - const [openSelectMaterialsDialog, closeSelectMaterialsDialog] = this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openSelectMaterialsDialog, close: closeSelectMaterialsDialog } = normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openSelectMaterialsDialog({ title: "Select Materials", onClose: closeSelectMaterialsDialog, @@ -321,25 +322,25 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin }); }; this.openSelectParentJobDialog = () => { - const [openSelectParentJobDialog, closeSelectParentJobDialog] = this.props.jobDialogs.selectParentJobExplorerDialog; + const { open: openSelectParentJobDialog, close: closeSelectParentJobDialog } = normalizeDialogHandle(this.props.jobDialogs.selectParentJobExplorerDialog); openSelectParentJobDialog({ onClose: closeSelectParentJobDialog, customActions: this.customJobsActions, }); }; this.openSelectWorkflowDialog = () => { - const [openSelectWorkflowDialog, closeSelectWorkflowDialog] = this.props.jobDialogs.selectWorkflowReduxDialog; + const { open: openSelectWorkflowDialog, close: closeSelectWorkflowDialog } = normalizeDialogHandle(this.props.jobDialogs.selectWorkflowReduxDialog); openSelectWorkflowDialog({ onClose: closeSelectWorkflowDialog, customActions: this.customWorkflowsActions, }); }; this.closeSelectWorkflowDialog = () => { - const [, closeSelectWorkflowDialog] = this.props.jobDialogs.selectWorkflowReduxDialog; + const { close: closeSelectWorkflowDialog } = normalizeDialogHandle(this.props.jobDialogs.selectWorkflowReduxDialog); closeSelectWorkflowDialog(); }; this.openDatasetUploadsDialog = () => { - const [openDatasetUploadsDialog, closeDatasetUploadsDialog] = this.props.jobDialogs.datasetUploadsReduxDialog; + const { open: openDatasetUploadsDialog, close: closeDatasetUploadsDialog } = normalizeDialogHandle(this.props.jobDialogs.datasetUploadsReduxDialog); openDatasetUploadsDialog({ onClose: closeDatasetUploadsDialog, account: this.props.profile.account.entity, @@ -493,6 +494,23 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin clusterMetadata: this.getPreflightContext().clusterMetadata, }); } + /** + * The "Select …" dialog that fills each step. + * + * This is what makes the rail a creation path rather than navigation: without + * it the only way to choose a material or a workflow is still the actions + * dropdown, which is the thing the rail exists to replace. Review has nothing + * to choose, so it gets no affordance. + */ + get readinessStepDialogs() { + if (!this.state.entity.isInInitialStatus) + return {}; + return { + material: this.openSelectMaterialsDialog, + dataset: this.openDatasetUploadsDialog, + workflow: this.openSelectWorkflowDialog, + }; + } /** * Core-hours the job will consume, and what they cost where the host told us * the price. Undefined until nodes, cores and a walltime are all set — the @@ -680,7 +698,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin }; } closeSelectParentJobDialog() { - const [, closeSelectParentJobDialog] = this.props.jobDialogs.selectParentJobExplorerDialog; + const { close: closeSelectParentJobDialog } = normalizeDialogHandle(this.props.jobDialogs.selectParentJobExplorerDialog); closeSelectParentJobDialog(); } render() { @@ -750,7 +768,7 @@ class Job extends mix(React.Component).with(StatePropsCompareOnUpdateForJobMIxin : undefined, children: [useGuidedDesigner ? (_jsx(JobReadinessRail, { steps: readiness.steps, activeStepId: this.state.currentTab === TAB_NAVIGATION_CONFIG.compute.id && readiness.isSubmittable ? TAB_NAVIGATION_CONFIG.compute.id - : this.state.currentTab, onSelect: this.onReadinessStepSelect })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable, + : this.state.currentTab, onSelect: this.onReadinessStepSelect, onChange: this.readinessStepDialogs, editable: editable })) : null, _jsx("div", { className: "tab-content", children: this.state.isWorkflowLoading ? (_jsx(LoadingIndicator, { included: true })) : (_jsxs(_Fragment, { children: [isCurrentTabMaterial && (_jsx(MaterialTab, { className: isCurrentTabMaterial ? "active" : null, id: TAB_NAVIGATION_CONFIG.material.id, publicAccount: publicAccount, profile: profile, role: "tabpanel", material: material, index: index, length: length, onUpdateIndex: onUpdateIndex, materials: materials, onMaterialRemove: onMaterialRemove, addRemoveAllowed: !job.id, openAddMaterialsDialog: this.openAddMaterialsDialog, MaterialViewerComponent: MaterialViewerComponent })), isCurrentTabDataset && (_jsx(DatasetTab, { className: isCurrentTabDataset ? "active" : null, id: TAB_NAVIGATION_CONFIG.dataset.id, profile: profile, role: "tabpanel", datasetConfig: datasetConfig, datagridHeaderText: "DataFrame", datagridPopoverText: "Training Model Data" })), isCurrentTabWorkflow && (_jsx(WorkflowTab, { className: isCurrentTabWorkflow ? "active" : null, workflowRenderGeneration: renderGeneration, id: TAB_NAVIGATION_CONFIG.workflow.id, role: "tabpanel", workflow: job.workflow, onJobRender: this.persistJob, jobHasParent: Boolean((_c = job.getParentJobClient) === null || _c === void 0 ? void 0 : _c.call(job)), profile: profile, publicAccount: publicAccount, allowedWorkflows: allowedWorkflows, onWorkflowSelect: onWorkflowSelect, materials: materials, materialsSet: materialsSet, materialsIndex: index, onIsMultiMaterialChanged: onIsMultiMaterialChanged, onMaterialSwitch: onMaterialSwitch, onWorkflowUpdate: this.onWorkflowUpdate, adjustable: job.isInInitialStatus, iconCls: `text-${job.statusCls}`, metaProperties: metaProperties, onOutputUpdateRequest: onOutputUpdateRequest, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, dialogs: workflowDialogs, templates: templates, createMetaProperty: createMetaProperty, jobProperties: jobProperties, isDescriptionEditable: isDescriptionEditable, // Phase 3.3 lives in @mat3ra/workflow-designer; // inert until a release carrying it is installed. useUnitInspector: useGuidedDesigner, useHostTheme: useGuidedDesigner })), isCurrentTabCompute && (_jsx(ComputeTab, { className: isCurrentTabCompute ? "active" : null, id: TAB_NAVIGATION_CONFIG.compute.id, role: "tabpanel", compute: job.compute, job: job, onUpdate: this.onComputeUpdate, editable: editable, clusters: clusters, showAdvancedOptions: showAdvancedCompute, accountUsers: accountUsers, accountUsersIsLoading: accountUsersIsLoading, currentUser: currentUser, currentAccount: currentAccount, diff --git a/dist/components/JobReadinessRail.d.ts b/dist/components/JobReadinessRail.d.ts index 313f7ad..d3158d3 100644 --- a/dist/components/JobReadinessRail.d.ts +++ b/dist/components/JobReadinessRail.d.ts @@ -4,6 +4,17 @@ export interface JobReadinessRailProps { steps: ReadinessStep[]; activeStepId: string; onSelect: (stepId: string) => void; + /** + * Opens the "Select …" dialog that fills a step, keyed by step id. A step + * with no entry gets no Change affordance — Review has nothing to choose. + * + * This is what makes the rail a creation path rather than just navigation: + * without it the only way to pick a material or a workflow is still the + * actions dropdown, which is the thing the rail exists to replace. + */ + onChange?: Record void) | undefined>; + /** False for shared or finished jobs: the rail renders without Change. */ + editable?: boolean; /** Rendered under the steps — parent job, import, and other power actions. */ children?: React.ReactNode; } @@ -18,5 +29,5 @@ export interface JobReadinessRailProps { * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, * and the active one carries `aria-current`. */ -export default function JobReadinessRail({ steps, activeStepId, onSelect, children, }: JobReadinessRailProps): React.JSX.Element; +export default function JobReadinessRail({ steps, activeStepId, onSelect, onChange, editable, children, }: JobReadinessRailProps): React.JSX.Element; //# sourceMappingURL=JobReadinessRail.d.ts.map \ No newline at end of file diff --git a/dist/components/JobReadinessRail.d.ts.map b/dist/components/JobReadinessRail.d.ts.map index e471ce4..7ff5365 100644 --- a/dist/components/JobReadinessRail.d.ts.map +++ b/dist/components/JobReadinessRail.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"JobReadinessRail.d.ts","sourceRoot":"","sources":["../../src/components/JobReadinessRail.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAarE,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACrC,KAAK,EACL,YAAY,EACZ,QAAQ,EACR,QAAQ,GACX,EAAE,qBAAqB,qBAuFvB"} \ No newline at end of file +{"version":3,"file":"JobReadinessRail.d.ts","sourceRoot":"","sources":["../../src/components/JobReadinessRail.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAcrE,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;IACpD,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,EACrC,KAAK,EACL,YAAY,EACZ,QAAQ,EACR,QAAa,EACb,QAAe,EACf,QAAQ,GACX,EAAE,qBAAqB,qBA+HvB"} \ No newline at end of file diff --git a/dist/components/JobReadinessRail.js b/dist/components/JobReadinessRail.js index acf9e8d..1e29a0d 100644 --- a/dist/components/JobReadinessRail.js +++ b/dist/components/JobReadinessRail.js @@ -1,10 +1,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import ButtonBase from "@mui/material/ButtonBase"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import React from "react"; +import { getMessage } from "../messages"; /** Icon and tone per step state. Never color alone — every state has a glyph. */ const STATE_PRESENTATION = { complete: { iconName: "shapes.check", color: "success.main" }, @@ -23,7 +25,7 @@ const STATE_PRESENTATION = { * Keyboard: the steps are a toolbar of buttons, arrow keys move between them, * and the active one carries `aria-current`. */ -export default function JobReadinessRail({ steps, activeStepId, onSelect, children, }) { +export default function JobReadinessRail({ steps, activeStepId, onSelect, onChange = {}, editable = true, children, }) { const stepRefs = React.useRef([]); const handleKeyDown = (event, index) => { var _a; @@ -35,30 +37,50 @@ export default function JobReadinessRail({ steps, activeStepId, onSelect, childr (_a = stepRefs.current[nextIndex]) === null || _a === void 0 ? void 0 : _a.focus(); }; return (_jsxs(Stack, { component: "nav", "aria-label": "Job steps", id: "job-readiness-rail", sx: { + // Under the md breakpoint the rail becomes a horizontal strip that + // scrolls: stacked full-width rows would push the step's own + // content off a narrow screen entirely. + flexDirection: { xs: "row", md: "column" }, + overflowX: { xs: "auto", md: "visible" }, borderRight: { md: "1px solid" }, borderBottom: { xs: "1px solid", md: "none" }, - borderColor: { xs: "divider", md: "divider" }, + borderColor: "divider", p: 1, minWidth: { md: 260 }, + maxWidth: { md: 320 }, gap: 0.25, }, children: [steps.map((step, index) => { const presentation = STATE_PRESENTATION[step.state]; const isActive = step.id === activeStepId; - return (_jsxs(ButtonBase, { ref: (node) => { - stepRefs.current[index] = node; - }, id: `job-step-${step.id}`, "aria-current": isActive ? "step" : undefined, disabled: step.state === "unavailable", onClick: () => onSelect(step.id), onKeyDown: (event) => handleKeyDown(event, index), sx: { - justifyContent: "flex-start", - gap: 1.25, - px: 1.5, - py: 1.25, + const change = editable ? onChange[step.id] : undefined; + return ( + // A sibling, not a child: the step row is a button, and a + // button inside a button is invalid and unreachable by + // keyboard. + _jsxs(Stack, { direction: "row", alignItems: "center", sx: { borderRadius: 1, - textAlign: "left", bgcolor: isActive ? "action.selected" : "transparent", "&:hover": { bgcolor: "action.hover" }, - "&.Mui-focusVisible": { - outline: "2px solid", - outlineColor: "primary.main", - }, - }, children: [_jsx(IconByName, { name: presentation.iconName, fontSize: "small", sx: { color: presentation.color } }), _jsxs(Box, { sx: { minWidth: 0 }, children: [_jsx(Typography, { variant: "body2", fontWeight: 600, noWrap: true, children: step.label }), _jsx(Typography, { variant: "caption", color: step.state === "attention" ? "warning.main" : "text.secondary", noWrap: true, sx: { display: "block" }, children: step.summary })] })] }, step.id)); + flexShrink: { xs: 0, md: 1 }, + }, children: [_jsxs(ButtonBase, { ref: (node) => { + stepRefs.current[index] = node; + }, id: `job-step-${step.id}`, "aria-current": isActive ? "step" : undefined, disabled: step.state === "unavailable", onClick: () => onSelect(step.id), onKeyDown: (event) => handleKeyDown(event, index), sx: { + flexGrow: 1, + justifyContent: "flex-start", + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: 1, + textAlign: "left", + minWidth: 0, + "&.Mui-focusVisible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }, children: [_jsx(IconByName, { name: presentation.iconName, fontSize: "small", sx: { color: presentation.color } }), _jsxs(Box, { sx: { minWidth: 0 }, children: [_jsx(Typography, { variant: "body2", fontWeight: 600, noWrap: true, children: step.label }), _jsx(Typography, { variant: "caption", color: step.state === "attention" + ? "warning.main" + : "text.secondary", noWrap: true, sx: { display: "block" }, children: step.summary })] })] }), change ? (_jsx(Button, { id: `job-step-change-${step.id}`, size: "small", onClick: change, sx: { flexShrink: 0, mr: 0.5 }, "aria-label": `${getMessage("rail.change")} ${step.label}`, children: step.state === "empty" + ? getMessage("rail.choose") + : getMessage("rail.change") })) : null] }, step.id)); }), children ? (_jsx(Box, { sx: { mt: 1, pt: 1, borderTop: "1px solid", borderColor: "divider" }, children: children })) : null] })); } diff --git a/dist/dialogHandles.d.ts b/dist/dialogHandles.d.ts new file mode 100644 index 0000000..e7d79ae --- /dev/null +++ b/dist/dialogHandles.d.ts @@ -0,0 +1,34 @@ +/** + * Opening a host-provided dialog, whichever shape the host gave it in. + * + * This package's own types disagree with its own code. `JobDesignerDialogState` + * describes `{ isOpen, open, close }`, which is what the container declares and + * what the standalone demo passes; `Job.jsx` destructures `[open, close]`, which + * is what `useReduxDialog` returns and therefore what the webapp actually flows + * through. Both shapes are real and present. + * + * Nothing caught it because the only route to these openers was the actions + * dropdown, which no test or demo run ever clicked. The rail's "Change" + * affordances put them one click from the front page, and the mismatch surfaced + * immediately as `object is not iterable`. + * + * Normalising is the honest fix rather than picking a winner: a host on either + * shape works, and neither has to be migrated before the rail ships. + */ +export type DialogTupleHandle = [(...args: unknown[]) => void, () => void]; +export interface DialogObjectHandle { + isOpen?: boolean; + open: (...args: unknown[]) => void; + close: () => void; +} +export type DialogHandle = DialogTupleHandle | DialogObjectHandle | undefined | null; +export interface NormalizedDialog { + open: (...args: unknown[]) => void; + close: () => void; +} +/** + * Always returns something callable. A missing dialog is a host that did not + * wire one up — the affordance should do nothing rather than throw on click. + */ +export declare function normalizeDialogHandle(handle: DialogHandle): NormalizedDialog; +//# sourceMappingURL=dialogHandles.d.ts.map \ No newline at end of file diff --git a/dist/dialogHandles.d.ts.map b/dist/dialogHandles.d.ts.map new file mode 100644 index 0000000..3cfe76f --- /dev/null +++ b/dist/dialogHandles.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"dialogHandles.d.ts","sourceRoot":"","sources":["../src/dialogHandles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE,MAAM,IAAI,CAAC,CAAC;AAE3E,MAAM,WAAW,kBAAkB;IAC/B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IACnC,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,MAAM,MAAM,YAAY,GAAG,iBAAiB,GAAG,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAAC;AAErF,MAAM,WAAW,gBAAgB;IAC7B,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IACnC,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB;AAID;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,gBAAgB,CAY5E"} \ No newline at end of file diff --git a/dist/dialogHandles.js b/dist/dialogHandles.js new file mode 100644 index 0000000..b44f707 --- /dev/null +++ b/dist/dialogHandles.js @@ -0,0 +1,33 @@ +/** + * Opening a host-provided dialog, whichever shape the host gave it in. + * + * This package's own types disagree with its own code. `JobDesignerDialogState` + * describes `{ isOpen, open, close }`, which is what the container declares and + * what the standalone demo passes; `Job.jsx` destructures `[open, close]`, which + * is what `useReduxDialog` returns and therefore what the webapp actually flows + * through. Both shapes are real and present. + * + * Nothing caught it because the only route to these openers was the actions + * dropdown, which no test or demo run ever clicked. The rail's "Change" + * affordances put them one click from the front page, and the mismatch surfaced + * immediately as `object is not iterable`. + * + * Normalising is the honest fix rather than picking a winner: a host on either + * shape works, and neither has to be migrated before the rail ships. + */ +const noop = () => undefined; +/** + * Always returns something callable. A missing dialog is a host that did not + * wire one up — the affordance should do nothing rather than throw on click. + */ +export function normalizeDialogHandle(handle) { + var _a; + if (Array.isArray(handle)) { + const [open, close] = handle; + return { open: open !== null && open !== void 0 ? open : noop, close: close !== null && close !== void 0 ? close : noop }; + } + if (handle && typeof handle.open === "function") { + return { open: handle.open, close: (_a = handle.close) !== null && _a !== void 0 ? _a : noop }; + } + return { open: noop, close: noop }; +} diff --git a/dist/messages.d.ts b/dist/messages.d.ts index 45415ec..3ad2ae8 100644 --- a/dist/messages.d.ts +++ b/dist/messages.d.ts @@ -47,6 +47,8 @@ export declare const MESSAGES: { readonly "readiness.results.summary": "Outputs and properties"; readonly "readiness.files.label": "Files"; readonly "readiness.files.summary": "Job directory"; + readonly "rail.choose": "Choose"; + readonly "rail.change": "Change"; readonly "blocker.material": "Select a material"; readonly "blocker.dataset": "Select a dataset"; readonly "blocker.workflow": "Select a workflow"; diff --git a/dist/messages.d.ts.map b/dist/messages.d.ts.map index 815f56c..f8f9331 100644 --- a/dist/messages.d.ts.map +++ b/dist/messages.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../src/messages.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAE5D,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,CAAC;AAE5F,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2FX,CAAC;AAEX,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,QAAQ,CAAC;AAe/C;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAc1E"} \ No newline at end of file +{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../src/messages.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAE5D,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,aAAa,KAAK,MAAM,GAAG,SAAS,CAAC;AAE5F,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+FX,CAAC;AAEX,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,QAAQ,CAAC;AAe/C;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAc1E"} \ No newline at end of file diff --git a/dist/messages.js b/dist/messages.js index c5ff6ba..6dbdc5e 100644 --- a/dist/messages.js +++ b/dist/messages.js @@ -28,6 +28,9 @@ export const MESSAGES = { "readiness.results.summary": "Outputs and properties", "readiness.files.label": "Files", "readiness.files.summary": "Job directory", + // The rail's per-step affordance for opening its "Select …" dialog. + "rail.choose": "Choose", + "rail.change": "Change", // Why Submit is disabled. Read by the button's tooltip and the preflight. "blocker.material": "Select a material", "blocker.dataset": "Select a dataset", diff --git a/dist/standalone/index.js b/dist/standalone/index.js index 372625f..e7479a9 100644 --- a/dist/standalone/index.js +++ b/dist/standalone/index.js @@ -152,6 +152,21 @@ const DEMO_CLUSTER_METADATA = [ ]; /** Fixed unix seconds so the simulated run reads the same on every reload. */ const SIMULATED_START = 1755000000; +/** + * A dialog handle in the tuple shape the webapp passes. The demo has no entity + * explorer to open, so it says which dialog would have opened rather than + * silently doing nothing — otherwise a broken wiring looks exactly like a + * working one. + */ +function demoDialog(name) { + return [ + () => { + // eslint-disable-next-line no-alert + window.alert(`${name} — the webapp opens its entity explorer here.`); + }, + () => { }, + ]; +} const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; function App() { const allWorkflowJsons = useMemo(() => { var _a; return (_a = new WorkflowStandata().getAll()) !== null && _a !== void 0 ? _a : []; }, []); @@ -269,27 +284,15 @@ function App() { user: { entity: { id: "1" } }, account: { entity: { id: "1" } }, personalAccount: { entity: { id: "1" } }, - }, publicAccount: { entity: { id: "public" } }, clusters: DEMO_CLUSTERS, refreshMetaProperties: () => { }, jobDialogs: { - selectMaterialsReduxDialog: { - isOpen: false, - open: () => { }, - close: () => { }, - }, - selectParentJobExplorerDialog: { - isOpen: false, - open: () => { }, - close: () => { }, - }, - selectWorkflowReduxDialog: { - isOpen: false, - open: () => { }, - close: () => { }, - }, - datasetUploadsReduxDialog: { - isOpen: false, - open: () => { }, - close: () => { }, - }, + }, publicAccount: { entity: { id: "public" } }, clusters: DEMO_CLUSTERS, refreshMetaProperties: () => { }, + // Tuples, as `useReduxDialog` returns and the webapp passes — + // the object form the demo used before did not match what + // `Job.jsx` destructures, so every "Select …" action threw. + jobDialogs: { + selectMaterialsReduxDialog: demoDialog("Select materials"), + selectParentJobExplorerDialog: demoDialog("Select parent job"), + selectWorkflowReduxDialog: demoDialog("Select workflow"), + datasetUploadsReduxDialog: demoDialog("Select dataset"), }, workflowDialogs: { pseudoUploadReduxDialog: [() => { }, () => { }], unitTypeReduxDialog: [() => { }, () => { }], diff --git a/src/components/Job.jsx b/src/components/Job.jsx index 0961fb5..6055421 100644 --- a/src/components/Job.jsx +++ b/src/components/Job.jsx @@ -21,6 +21,7 @@ import { TAB_NAVIGATION_CONFIG } from "@mat3ra/jode"; import { ANALYTICS_EVENTS, durationSince, trackEvent } from "../analytics"; import { estimateComputeUsage, formatEstimate } from "../computeEstimate"; import { formatBlockedReason } from "../jobSubmission"; +import { normalizeDialogHandle } from "../dialogHandles"; import { getJobReadiness } from "../jobReadiness"; import { getSaveState, getSaveStateLabel, shouldWarnBeforeLeaving } from "../saveState"; import { shouldPersistJobOnUpdate } from "../shouldPersistJobOnUpdate"; @@ -280,6 +281,24 @@ class Job extends mix(React.Component).with( }); } + /** + * The "Select …" dialog that fills each step. + * + * This is what makes the rail a creation path rather than navigation: without + * it the only way to choose a material or a workflow is still the actions + * dropdown, which is the thing the rail exists to replace. Review has nothing + * to choose, so it gets no affordance. + */ + get readinessStepDialogs() { + if (!this.state.entity.isInInitialStatus) return {}; + + return { + material: this.openSelectMaterialsDialog, + dataset: this.openDatasetUploadsDialog, + workflow: this.openSelectWorkflowDialog, + }; + } + /** The rail's Review step has no tab of its own; it lands on Compute. */ onReadinessStepSelect = (stepId) => { // The step a session ends on is where abandonment happens. @@ -768,8 +787,8 @@ class Job extends mix(React.Component).with( }; openAddMaterialsDialog = () => { - const [openAddMaterialsDialog, closeAddMaterialsDialog] = - this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openAddMaterialsDialog, close: closeAddMaterialsDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openAddMaterialsDialog({ id: "material-add", @@ -785,8 +804,8 @@ class Job extends mix(React.Component).with( }; openSelectMaterialsDialog = () => { - const [openSelectMaterialsDialog, closeSelectMaterialsDialog] = - this.props.jobDialogs.selectMaterialsReduxDialog; + const { open: openSelectMaterialsDialog, close: closeSelectMaterialsDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectMaterialsReduxDialog); openSelectMaterialsDialog({ title: "Select Materials", @@ -801,8 +820,8 @@ class Job extends mix(React.Component).with( }; openSelectParentJobDialog = () => { - const [openSelectParentJobDialog, closeSelectParentJobDialog] = - this.props.jobDialogs.selectParentJobExplorerDialog; + const { open: openSelectParentJobDialog, close: closeSelectParentJobDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectParentJobExplorerDialog); openSelectParentJobDialog({ onClose: closeSelectParentJobDialog, @@ -811,14 +830,16 @@ class Job extends mix(React.Component).with( }; closeSelectParentJobDialog() { - const [, closeSelectParentJobDialog] = this.props.jobDialogs.selectParentJobExplorerDialog; + const { close: closeSelectParentJobDialog } = normalizeDialogHandle( + this.props.jobDialogs.selectParentJobExplorerDialog, + ); closeSelectParentJobDialog(); } openSelectWorkflowDialog = () => { - const [openSelectWorkflowDialog, closeSelectWorkflowDialog] = - this.props.jobDialogs.selectWorkflowReduxDialog; + const { open: openSelectWorkflowDialog, close: closeSelectWorkflowDialog } = + normalizeDialogHandle(this.props.jobDialogs.selectWorkflowReduxDialog); openSelectWorkflowDialog({ onClose: closeSelectWorkflowDialog, @@ -827,14 +848,16 @@ class Job extends mix(React.Component).with( }; closeSelectWorkflowDialog = () => { - const [, closeSelectWorkflowDialog] = this.props.jobDialogs.selectWorkflowReduxDialog; + const { close: closeSelectWorkflowDialog } = normalizeDialogHandle( + this.props.jobDialogs.selectWorkflowReduxDialog, + ); closeSelectWorkflowDialog(); }; openDatasetUploadsDialog = () => { - const [openDatasetUploadsDialog, closeDatasetUploadsDialog] = - this.props.jobDialogs.datasetUploadsReduxDialog; + const { open: openDatasetUploadsDialog, close: closeDatasetUploadsDialog } = + normalizeDialogHandle(this.props.jobDialogs.datasetUploadsReduxDialog); openDatasetUploadsDialog({ onClose: closeDatasetUploadsDialog, @@ -1071,6 +1094,8 @@ class Job extends mix(React.Component).with( : this.state.currentTab } onSelect={this.onReadinessStepSelect} + onChange={this.readinessStepDialogs} + editable={editable} /> ) : null}
diff --git a/src/components/JobReadinessRail.tsx b/src/components/JobReadinessRail.tsx index 2574249..e91aefe 100644 --- a/src/components/JobReadinessRail.tsx +++ b/src/components/JobReadinessRail.tsx @@ -1,11 +1,13 @@ import IconByName from "@mat3ra/cove/dist/mui/components/icon/IconByName"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import ButtonBase from "@mui/material/ButtonBase"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import React from "react"; import type { ReadinessState, ReadinessStep } from "../jobReadiness"; +import { getMessage } from "../messages"; /** Icon and tone per step state. Never color alone — every state has a glyph. */ const STATE_PRESENTATION: Record< @@ -22,6 +24,17 @@ export interface JobReadinessRailProps { steps: ReadinessStep[]; activeStepId: string; onSelect: (stepId: string) => void; + /** + * Opens the "Select …" dialog that fills a step, keyed by step id. A step + * with no entry gets no Change affordance — Review has nothing to choose. + * + * This is what makes the rail a creation path rather than just navigation: + * without it the only way to pick a material or a workflow is still the + * actions dropdown, which is the thing the rail exists to replace. + */ + onChange?: Record void) | undefined>; + /** False for shared or finished jobs: the rail renders without Change. */ + editable?: boolean; /** Rendered under the steps — parent job, import, and other power actions. */ children?: React.ReactNode; } @@ -41,6 +54,8 @@ export default function JobReadinessRail({ steps, activeStepId, onSelect, + onChange = {}, + editable = true, children, }: JobReadinessRailProps) { const stepRefs = React.useRef>([]); @@ -60,65 +75,105 @@ export default function JobReadinessRail({ aria-label="Job steps" id="job-readiness-rail" sx={{ + // Under the md breakpoint the rail becomes a horizontal strip that + // scrolls: stacked full-width rows would push the step's own + // content off a narrow screen entirely. + flexDirection: { xs: "row", md: "column" }, + overflowX: { xs: "auto", md: "visible" }, borderRight: { md: "1px solid" }, borderBottom: { xs: "1px solid", md: "none" }, - borderColor: { xs: "divider", md: "divider" }, + borderColor: "divider", p: 1, minWidth: { md: 260 }, + maxWidth: { md: 320 }, gap: 0.25, }} > {steps.map((step, index) => { const presentation = STATE_PRESENTATION[step.state]; const isActive = step.id === activeStepId; + const change = editable ? onChange[step.id] : undefined; return ( - { - stepRefs.current[index] = node as HTMLButtonElement | null; - }} - id={`job-step-${step.id}`} - aria-current={isActive ? "step" : undefined} - disabled={step.state === "unavailable"} - onClick={() => onSelect(step.id)} - onKeyDown={(event) => handleKeyDown(event, index)} + direction="row" + alignItems="center" sx={{ - justifyContent: "flex-start", - gap: 1.25, - px: 1.5, - py: 1.25, borderRadius: 1, - textAlign: "left", bgcolor: isActive ? "action.selected" : "transparent", "&:hover": { bgcolor: "action.hover" }, - "&.Mui-focusVisible": { - outline: "2px solid", - outlineColor: "primary.main", - }, + flexShrink: { xs: 0, md: 1 }, }} > - - - - {step.label} - - { + stepRefs.current[index] = node as HTMLButtonElement | null; + }} + id={`job-step-${step.id}`} + aria-current={isActive ? "step" : undefined} + disabled={step.state === "unavailable"} + onClick={() => onSelect(step.id)} + onKeyDown={(event) => handleKeyDown(event, index)} + sx={{ + flexGrow: 1, + justifyContent: "flex-start", + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: 1, + textAlign: "left", + minWidth: 0, + "&.Mui-focusVisible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }} + > + + + + {step.label} + + + {step.summary} + + + + + {change ? ( + + ) : null} + ); })} diff --git a/src/dialogHandles.ts b/src/dialogHandles.ts new file mode 100644 index 0000000..efb6776 --- /dev/null +++ b/src/dialogHandles.ts @@ -0,0 +1,52 @@ +/** + * Opening a host-provided dialog, whichever shape the host gave it in. + * + * This package's own types disagree with its own code. `JobDesignerDialogState` + * describes `{ isOpen, open, close }`, which is what the container declares and + * what the standalone demo passes; `Job.jsx` destructures `[open, close]`, which + * is what `useReduxDialog` returns and therefore what the webapp actually flows + * through. Both shapes are real and present. + * + * Nothing caught it because the only route to these openers was the actions + * dropdown, which no test or demo run ever clicked. The rail's "Change" + * affordances put them one click from the front page, and the mismatch surfaced + * immediately as `object is not iterable`. + * + * Normalising is the honest fix rather than picking a winner: a host on either + * shape works, and neither has to be migrated before the rail ships. + */ + +export type DialogTupleHandle = [(...args: unknown[]) => void, () => void]; + +export interface DialogObjectHandle { + isOpen?: boolean; + open: (...args: unknown[]) => void; + close: () => void; +} + +export type DialogHandle = DialogTupleHandle | DialogObjectHandle | undefined | null; + +export interface NormalizedDialog { + open: (...args: unknown[]) => void; + close: () => void; +} + +const noop = () => undefined; + +/** + * Always returns something callable. A missing dialog is a host that did not + * wire one up — the affordance should do nothing rather than throw on click. + */ +export function normalizeDialogHandle(handle: DialogHandle): NormalizedDialog { + if (Array.isArray(handle)) { + const [open, close] = handle; + + return { open: open ?? noop, close: close ?? noop }; + } + + if (handle && typeof handle.open === "function") { + return { open: handle.open, close: handle.close ?? noop }; + } + + return { open: noop, close: noop }; +} diff --git a/src/messages.ts b/src/messages.ts index 8490897..32d0497 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -54,6 +54,10 @@ export const MESSAGES = { "readiness.files.label": "Files", "readiness.files.summary": "Job directory", + // The rail's per-step affordance for opening its "Select …" dialog. + "rail.choose": "Choose", + "rail.change": "Change", + // Why Submit is disabled. Read by the button's tooltip and the preflight. "blocker.material": "Select a material", "blocker.dataset": "Select a dataset", diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 233416f..26e8aa4 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -177,6 +177,22 @@ const DEMO_CLUSTER_METADATA = [ /** Fixed unix seconds so the simulated run reads the same on every reload. */ const SIMULATED_START = 1_755_000_000; +/** + * A dialog handle in the tuple shape the webapp passes. The demo has no entity + * explorer to open, so it says which dialog would have opened rather than + * silently doing nothing — otherwise a broken wiring looks exactly like a + * working one. + */ +function demoDialog(name: string): [(...args: unknown[]) => void, () => void] { + return [ + () => { + // eslint-disable-next-line no-alert + window.alert(`${name} — the webapp opens its entity explorer here.`); + }, + () => {}, + ]; +} + const DEMO_QUOTA = { remainingCoreHours: 500, totalCoreHours: 1000, currency: "USD" }; function App() { @@ -414,28 +430,17 @@ function App() { publicAccount={{ entity: { id: "public" } } as any} clusters={DEMO_CLUSTERS} refreshMetaProperties={() => {}} - jobDialogs={{ - selectMaterialsReduxDialog: { - isOpen: false, - open: () => {}, - close: () => {}, - }, - selectParentJobExplorerDialog: { - isOpen: false, - open: () => {}, - close: () => {}, - }, - selectWorkflowReduxDialog: { - isOpen: false, - open: () => {}, - close: () => {}, - }, - datasetUploadsReduxDialog: { - isOpen: false, - open: () => {}, - close: () => {}, - }, - }} + // Tuples, as `useReduxDialog` returns and the webapp passes — + // the object form the demo used before did not match what + // `Job.jsx` destructures, so every "Select …" action threw. + jobDialogs={ + { + selectMaterialsReduxDialog: demoDialog("Select materials"), + selectParentJobExplorerDialog: demoDialog("Select parent job"), + selectWorkflowReduxDialog: demoDialog("Select workflow"), + datasetUploadsReduxDialog: demoDialog("Select dataset"), + } as any + } workflowDialogs={{ pseudoUploadReduxDialog: [() => {}, () => {}] as any, unitTypeReduxDialog: [() => {}, () => {}] as any, diff --git a/tests/dialogHandles.tests.ts b/tests/dialogHandles.tests.ts new file mode 100644 index 0000000..606d815 --- /dev/null +++ b/tests/dialogHandles.tests.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { normalizeDialogHandle } from "../src/dialogHandles"; + +describe("normalizeDialogHandle", () => { + it("opens a tuple handle, which is what the webapp passes", () => { + const calls: string[] = []; + const dialog = normalizeDialogHandle([() => calls.push("open"), () => calls.push("close")]); + dialog.open(); + dialog.close(); + assert.deepEqual(calls, ["open", "close"]); + }); + + it("opens an object handle, which is what the container's type declares", () => { + // Both shapes are real and present in this codebase; picking a winner + // would break whichever host is on the other one. + const calls: string[] = []; + const dialog = normalizeDialogHandle({ + isOpen: false, + open: () => calls.push("open"), + close: () => calls.push("close"), + }); + dialog.open(); + dialog.close(); + assert.deepEqual(calls, ["open", "close"]); + }); + + it("passes the dialog's configuration through", () => { + const received: unknown[] = []; + normalizeDialogHandle([ + (...args: unknown[]) => received.push(...args), + () => undefined, + ]).open({ title: "Select Materials" }); + assert.deepEqual(received, [{ title: "Select Materials" }]); + }); + + it("does nothing rather than throwing when the host wired no dialog", () => { + // The rail puts these one click from the front page; an affordance that + // throws is worse than one that quietly does nothing. + assert.doesNotThrow(() => normalizeDialogHandle(undefined).open()); + assert.doesNotThrow(() => normalizeDialogHandle(null).close()); + assert.doesNotThrow(() => normalizeDialogHandle({} as never).open()); + }); + + it("survives a half-built handle", () => { + assert.doesNotThrow(() => + normalizeDialogHandle([undefined as never, undefined as never]).open(), + ); + assert.doesNotThrow(() => + normalizeDialogHandle({ open: () => undefined } as never).close(), + ); + }); +}); From 57fd7ed38e5614974c27fea9544f088e7dc253d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:59:49 +0000 Subject: [PATCH 29/33] docs: record what auditing the acceptance criteria found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked each phase-2 criterion against the running app rather than against my own notes. 2.2, 2.4 and 2.6 hold as claimed — the context chips stay visible on every step, an incomplete job cannot be submitted, and the save indicator and leave-guard both flip on an edit. 2.1's two misses are fixed in the previous commit. 2.5 gains a demo toggle for one-vs-three materials, because the batch multiplier — the thing the plan says surprises readers most — could not be reviewed with a single material. Verified: three tray chips over "3 materials — the workflow runs 3 times, once per material", with the rail step and context chip both reading "3 materials — runs 3 times". Its removal half stays unverified, and the reason is worth a product decision rather than a workaround: `addRemoveAllowed={!job.id}` switches the tray's Add and Remove off the moment a draft is saved, while `editable` still reports the job as editable. That predates this work, but the guided designer makes it matter — the tray is now the primary affordance for materials and is inert on any saved draft. Recorded as open question 5; not changed. Refs SOF-8023. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DK8KomMescJvMQNSEfeRR8 --- dist/standalone/index.js | 16 ++++++++-- ...17-Job-Designer-Phase-2-Guided-Designer.md | 31 +++++++++++++++++++ ...08-16-Job-Designer-Guided-Designer-Plan.md | 4 +++ src/standalone/index.tsx | 28 +++++++++++++++-- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/dist/standalone/index.js b/dist/standalone/index.js index e7479a9..91c6b1f 100644 --- a/dist/standalone/index.js +++ b/dist/standalone/index.js @@ -188,11 +188,21 @@ function App() { // so the monitor, the lifecycle timeline past Draft, and the rail's Monitor // step would never be reviewable. This starts the job already running. const [isRunSimulated, setIsRunSimulated] = useState(false); + // The batch multiplier is the thing the plan says surprises readers most — + // "3 materials, the workflow runs 3 times" — and with a single material the + // tray copy, the rail summary and the ×3 estimate could never be reviewed. + const [isBatch, setIsBatch] = useState(false); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m) => { var _a; return /silicon|^si\b/i.test((_a = m.name) !== null && _a !== void 0 ? _a : ""); }); return idx >= 0 ? idx : 0; }); const selectedMaterial = useMemo(() => new Material(allMaterialJsons[materialIndex]), [materialIndex, allMaterialJsons]); + /** One material, or that one plus its two neighbours as a batch. */ + const selectedMaterials = useMemo(() => { + if (!isBatch) + return [selectedMaterial]; + return [0, 1, 2].map((offset) => new Material(allMaterialJsons[(materialIndex + offset) % allMaterialJsons.length])); + }, [isBatch, selectedMaterial, materialIndex, allMaterialJsons]); const jobRef = useRef(null); const job = useMemo(() => { var _a, _b, _c, _d; @@ -257,7 +267,7 @@ function App() { }; // Remount on a simulated-run flip too: the container builds its redux store from // the job it is first given, so a new Job instance alone would not be picked up. - const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}`; + const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}-${isBatch}`; if (!wodeWorkflow || !selectedMaterial || !job) { return (_jsx(Box, { p: 4, children: _jsx(Typography, { children: "Loading..." }) })); } @@ -273,14 +283,14 @@ function App() { }) })] })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(ScienceIcon, { fontSize: "small", sx: { color: "secondary.main", flexShrink: 0 } }), _jsxs(FormControl, { size: "small", sx: { minWidth: 240 }, children: [_jsx(InputLabel, { id: "material-select-label", children: "Material" }), _jsx(Select, { labelId: "material-select-label", value: materialIndex, label: "Material", onChange: (e) => setMaterialIndex(Number(e.target.value)), children: allMaterialJsons.map((mat, i) => { var _a, _b; return (_jsx(MenuItem, { value: i, children: (_b = (_a = mat === null || mat === void 0 ? void 0 : mat.name) !== null && _a !== void 0 ? _a : mat === null || mat === void 0 ? void 0 : mat.formula) !== null && _b !== void 0 ? _b : `Material ${i + 1}` }, i)); - }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setUseGuidedDesigner((isOn) => !isOn), sx: { mr: 1 }, children: useGuidedDesigner ? "Guided layout: on" : "Guided layout: off" }), _jsx(Button, { size: "small", variant: isRunSimulated ? "contained" : "outlined", onClick: () => setIsRunSimulated((on) => !on), "data-tid": "simulate-run-toggle", children: isRunSimulated ? "Job: running" : "Job: draft" }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { + }) })] })] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(Tooltip, { title: `${allWorkflowJsons.length} workflows · ${allMaterialJsons.length} materials from standata`, children: _jsx(Chip, { label: "standata", size: "small", variant: "outlined", color: "secondary" }) }), _jsx(Box, { sx: { flexGrow: 1 } }), _jsx(Button, { variant: "outlined", size: "small", onClick: () => setUseGuidedDesigner((isOn) => !isOn), sx: { mr: 1 }, children: useGuidedDesigner ? "Guided layout: on" : "Guided layout: off" }), _jsx(Button, { size: "small", variant: isRunSimulated ? "contained" : "outlined", onClick: () => setIsRunSimulated((on) => !on), "data-tid": "simulate-run-toggle", children: isRunSimulated ? "Job: running" : "Job: draft" }), _jsx(Button, { size: "small", variant: isBatch ? "contained" : "outlined", onClick: () => setIsBatch((on) => !on), "data-tid": "batch-toggle", children: isBatch ? "Materials: 3" : "Materials: 1" }), _jsx(Button, { variant: "outlined", size: "small", startIcon: _jsx(DownloadIcon, {}), onClick: handleExportJson, sx: { borderColor: "rgba(124,77,255,0.5)", color: "primary.main", "&:hover": { borderColor: "primary.main", bgcolor: "rgba(124,77,255,0.08)", }, - }, children: "Export JSON" })] }) }), _jsx(JobDesignerProvider, { deps: { getRouteQueryTab: () => "workflow" }, children: _jsx(JobLocalReduxContainer, { job: job, jobMaterials: [selectedMaterial], materials: [selectedMaterial], project: { name: "Demo Project", _id: "standalone-project" }, metaProperties: [], accountUsers: [], accountUsersIsLoading: false, profile: { + }, children: "Export JSON" })] }) }), _jsx(JobDesignerProvider, { deps: { getRouteQueryTab: () => "workflow" }, children: _jsx(JobLocalReduxContainer, { job: job, jobMaterials: selectedMaterials, materials: selectedMaterials, project: { name: "Demo Project", _id: "standalone-project" }, metaProperties: [], accountUsers: [], accountUsersIsLoading: false, profile: { user: { entity: { id: "1" } }, account: { entity: { id: "1" } }, personalAccount: { entity: { id: "1" } }, diff --git a/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md index eb340f0..7bdedac 100644 --- a/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md +++ b/plan/review/2026-08-17-Job-Designer-Phase-2-Guided-Designer.md @@ -76,6 +76,25 @@ acknowledged, then releases it. quota. A fifth — *Saved* — was added, because "the job has never been saved" was otherwise reported only by a disabled button. +### Found by auditing the acceptance criteria against the running app + +Two of 2.1's criteria were recorded as built and were not; both are now fixed (see the +"Built" note under 2.1). A third is a question rather than a defect: + +- **A saved draft cannot change its materials.** `MaterialTab` receives + `addRemoveAllowed={!job.id}`, so the tray's Add and Remove affordances disappear the moment + a draft is saved — while `editable` (which is `status === pre_submission`) stays true, so + the designer simultaneously reports the job as editable. This predates the guided designer, + but the guided designer makes it matter much more: the tray is now the *primary* affordance + for materials, and on any saved draft it is inert. Whether the rule is intended is a product + question — materials may well be baked into the saved job document — so it is recorded here + rather than changed. The rail's "Change Material" affordance is gated on + `isInInitialStatus` instead, so the selector itself remains reachable. +- Consequently **"removing the active material selects a sane neighbour" is unverified in the + demo**: its job carries an `_id` so that Submit is not permanently blocked on "Save the + job", which switches the remove affordance off. The batch copy either side of it is + verified — see below. + ### Still open - **Queue-derived limits.** Limits come from `clusterMetadata` per cluster; real queues @@ -128,6 +147,14 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, with creation steps summarized; the rail is keyboard-navigable (arrow keys between steps, visible focus, `aria-current` on the active step). Size: L. - **Built** — `src/jobReadiness.ts` (16 unit tests) and `src/components/JobReadinessRail.tsx`. + Two criteria were initially missed and fixed after an audit against the running app: each + step that owns a "Select …" dialog now carries a **Change** (or **Choose**) affordance, so + the headline acceptance — creating a job without opening the dropdown — actually holds; and + the rail **collapses to a horizontal scrolling strip below the md breakpoint** rather than + stacking full-width rows that pushed the step's own content off a narrow screen. Adding the + affordances exposed a crash (`object is not iterable`): the package's dialog types describe + `{ isOpen, open, close }` while `Job.jsx` destructures `[open, close]`, and only the + never-clicked dropdown had ever reached them. `normalizeDialogHandle` accepts either. `@mat3ra/jode` was left alone: the rail takes its order and labels from the selector and reuses the existing tab ids, so deep links keep working without new schema. The selector also reads host-published cluster limits, so the Compute step says *over the 12 h queue @@ -206,6 +233,10 @@ The layout change. Mockups: `01-guided-designer.html`, `02-compute-cost.html`, `Material` model next to the injected viewer; the viewer component API is unchanged. - Acceptance: adding a second material updates the tray, the batch copy, and the context strip; removing the active material selects a sane neighbor. Size: M. +- **Verified in the demo** with its new 1-vs-3 materials toggle: three chips in the tray over + "3 materials — the workflow runs 3 times, once per material.", with the rail step and the + context chip both reading "3 materials — runs 3 times". The removal half could not be + exercised — see the audit note above. - **Built** — [job-designer#19](https://github.com/mat3ra/job-designer/pull/19). Divergence: the metadata is read through a defensive `getMaterialSummary()` that omits any field it cannot read, because `MaterialTab` already renders a fallback for hosts passing a plain diff --git a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md index ff8b529..9967f8f 100644 --- a/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md +++ b/plan/upcoming/2026-08-16-Job-Designer-Guided-Designer-Plan.md @@ -277,6 +277,10 @@ recorder and a dashboard — the events exist, nothing is collecting them yet. 3. Does the monitor (3.2) poll job properties or can the webapp provide a push channel? Polling is acceptable for v1. 4. File per-phase SOF tickets under SOF-8023 — one per phase, or one per proposal group? +5. Should a **saved draft** be able to change its materials? `MaterialTab`'s + `addRemoveAllowed={!job.id}` says no, while `editable` says the job is still editable. The + guided designer makes the contradiction visible, because the materials tray is now the + primary affordance and is inert on any saved draft. Pre-existing; not changed here. (Suggest one per phase.) 5. Which analytics channel should the success-metrics events use — the webapp's existing telemetry, or is this the moment to add a product-analytics hook to the seam? (Gates the diff --git a/src/standalone/index.tsx b/src/standalone/index.tsx index 26e8aa4..db39c43 100644 --- a/src/standalone/index.tsx +++ b/src/standalone/index.tsx @@ -219,6 +219,10 @@ function App() { // so the monitor, the lifecycle timeline past Draft, and the rail's Monitor // step would never be reviewable. This starts the job already running. const [isRunSimulated, setIsRunSimulated] = useState(false); + // The batch multiplier is the thing the plan says surprises readers most — + // "3 materials, the workflow runs 3 times" — and with a single material the + // tray copy, the rail summary and the ×3 estimate could never be reviewed. + const [isBatch, setIsBatch] = useState(false); const [materialIndex, setMaterialIndex] = useState(() => { const idx = allMaterialJsons.findIndex((m: any) => /silicon|^si\b/i.test(m.name ?? "")); return idx >= 0 ? idx : 0; @@ -228,6 +232,16 @@ function App() { [materialIndex, allMaterialJsons], ); + /** One material, or that one plus its two neighbours as a batch. */ + const selectedMaterials = useMemo(() => { + if (!isBatch) return [selectedMaterial]; + + return [0, 1, 2].map( + (offset) => + new Material(allMaterialJsons[(materialIndex + offset) % allMaterialJsons.length]), + ); + }, [isBatch, selectedMaterial, materialIndex, allMaterialJsons]); + const jobRef = useRef | null>(null); const job = useMemo(() => { @@ -295,7 +309,7 @@ function App() { // Remount on a simulated-run flip too: the container builds its redux store from // the job it is first given, so a new Job instance alone would not be picked up. - const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}`; + const designerKey = `${workflowIndex}-${materialIndex}-${isRunSimulated}-${isBatch}`; if (!wodeWorkflow || !selectedMaterial || !job) { return ( @@ -389,6 +403,14 @@ function App() { > {isRunSimulated ? "Job: running" : "Job: draft"} +