diff --git a/crates/procnote-core/src/event/log.rs b/crates/procnote-core/src/event/log.rs index 14cc6d0..f6fea80 100644 --- a/crates/procnote-core/src/event/log.rs +++ b/crates/procnote-core/src/event/log.rs @@ -263,6 +263,14 @@ mod tests { content_type: "image/jpeg".to_string(), sha256: "abc123".to_string(), }, + Event::StepContentUpdated { + at: now, + execution_id: id, + step_heading: "Step 1".to_string(), + content: vec![StepContent::Prose { + text: "Updated description".to_string(), + }], + }, ]; // Round-trip all event types through JSON. diff --git a/crates/procnote-core/src/event/types.rs b/crates/procnote-core/src/event/types.rs index e62864d..0946181 100644 --- a/crates/procnote-core/src/event/types.rs +++ b/crates/procnote-core/src/event/types.rs @@ -109,6 +109,14 @@ pub enum Event { sha256: String, }, + // -- Step Content -- + StepContentUpdated { + at: DateTime, + execution_id: ExecutionId, + step_heading: String, + content: Vec, + }, + // -- Name -- ExecutionRenamed { at: DateTime, @@ -161,6 +169,7 @@ impl Event { | Self::InputRecorded { .. } | Self::NoteAdded { .. } | Self::AttachmentAdded { .. } + | Self::StepContentUpdated { .. } | Self::ExecutionRenamed { .. } => Revertibility::Revertible, // Revert marker — not revertible @@ -237,6 +246,9 @@ impl Event { } => { format!("Recorded {label} = {filename} in {step_heading}") } + Self::StepContentUpdated { step_heading, .. } => { + format!("Updated content of step: {step_heading}") + } Self::ExecutionRenamed { name, .. } => { format!("Renamed execution to: {name}") } diff --git a/crates/procnote-core/src/execution/engine.rs b/crates/procnote-core/src/execution/engine.rs index 880ef17..a27e4be 100644 --- a/crates/procnote-core/src/execution/engine.rs +++ b/crates/procnote-core/src/execution/engine.rs @@ -321,6 +321,16 @@ impl ExecutionState { ); } + Event::StepContentUpdated { + step_heading, + content, + .. + } => { + self.require_active()?; + let step = self.get_step_mut(step_heading)?; + step.content.clone_from(content); + } + Event::ExecutionRenamed { name, .. } => { if self.execution_id.is_none() { return Err(ExecutionError::NotStarted); @@ -419,6 +429,23 @@ impl ExecutionState { Ok(event) } + /// Update the content of an existing step. + pub fn update_step_content( + &mut self, + step_heading: &str, + content: Vec, + ) -> Result { + self.require_active()?; + let event = Event::StepContentUpdated { + at: Utc::now(), + execution_id: self.require_execution_id()?, + step_heading: step_heading.to_string(), + content, + }; + self.apply(&event)?; + Ok(event) + } + /// Start a step. pub fn start_step(&mut self, heading: &str) -> Result { self.require_active()?; @@ -1234,4 +1261,148 @@ mod tests { .contains_key("Current") ); } + + // -- StepContentUpdated tests -- + + #[test] + fn test_update_step_content() { + let template = sample_template(); + let mut state = ExecutionState::new(); + state.start(&template).unwrap(); + + let new_content = vec![StepContent::Prose { + text: "Updated description".to_string(), + }]; + state + .update_step_content("Preconditions", new_content) + .unwrap(); + + assert_eq!(state.steps["Preconditions"].content.len(), 1); + assert!( + matches!(&state.steps["Preconditions"].content[0], StepContent::Prose { text } if text == "Updated description") + ); + } + + #[test] + fn test_update_step_content_preserves_inputs_and_notes() { + let template = sample_template(); + let mut state = ExecutionState::new(); + state.start(&template).unwrap(); + state.start_step("Preconditions").unwrap(); + state + .record_input("Preconditions", "Voltage", "5.0", Some("V")) + .unwrap(); + state + .add_note("Some observation", Some("Preconditions")) + .unwrap(); + + let new_content = vec![StepContent::Prose { + text: "New prose".to_string(), + }]; + state + .update_step_content("Preconditions", new_content) + .unwrap(); + + // Content is replaced. + assert_eq!(state.steps["Preconditions"].content.len(), 1); + // Inputs and notes are untouched. + assert_eq!(state.steps["Preconditions"].inputs["Voltage"].value, "5.0"); + assert_eq!(state.steps["Preconditions"].notes.len(), 1); + assert_eq!(state.steps["Preconditions"].notes[0], "Some observation"); + } + + #[test] + fn test_cannot_update_content_before_start() { + let mut state = ExecutionState::new(); + let result = state.update_step_content("Step 1", vec![]); + assert_eq!(result.unwrap_err(), ExecutionError::NotStarted); + } + + #[test] + fn test_cannot_update_content_after_finish() { + let template = sample_template(); + let mut state = ExecutionState::new(); + state.start(&template).unwrap(); + state.complete(CompletionStatus::Pass).unwrap(); + + let result = state.update_step_content("Preconditions", vec![]); + assert_eq!(result.unwrap_err(), ExecutionError::AlreadyFinished); + } + + #[test] + fn test_cannot_update_content_nonexistent_step() { + let template = sample_template(); + let mut state = ExecutionState::new(); + state.start(&template).unwrap(); + + let result = state.update_step_content("Nonexistent Step", vec![]); + assert_eq!( + result.unwrap_err(), + ExecutionError::StepNotFound("Nonexistent Step".to_string()) + ); + } + + #[test] + fn test_revert_step_content_updated() { + let template = sample_template(); + let mut state = ExecutionState::new(); + let mut events: Vec = Vec::new(); + events.extend(state.start(&template).unwrap()); + // index 0..4: ExecutionStarted + ExecutionRenamed + 3 StepAdded + + let original_content = state.steps["Preconditions"].content.clone(); + + events.push( + state + .update_step_content( + "Preconditions", + vec![StepContent::Prose { + text: "Changed".to_string(), + }], + ) + .unwrap(), + ); // index 5 + + // Revert the content update. + let revert = ExecutionState::revert_event(&events, 5, "undo edit").unwrap(); + events.push(revert); + + let rebuilt = ExecutionState::from_events(&events).unwrap(); + assert_eq!(rebuilt.steps["Preconditions"].content, original_content); + } + + #[test] + fn test_update_step_content_replay() { + let template = sample_template(); + let mut state = ExecutionState::new(); + let mut events: Vec = Vec::new(); + events.extend(state.start(&template).unwrap()); + + events.push( + state + .update_step_content( + "Preconditions", + vec![StepContent::Prose { + text: "Updated via replay".to_string(), + }], + ) + .unwrap(), + ); + + // Serialize and deserialize all events. + let jsons: Vec = events + .iter() + .map(|e| serde_json::to_string(e).unwrap()) + .collect(); + let deserialized_events: Vec = jsons + .iter() + .map(|j| serde_json::from_str(j).unwrap()) + .collect(); + + let replayed = ExecutionState::from_events(&deserialized_events).unwrap(); + assert_eq!(replayed.steps["Preconditions"].content.len(), 1); + assert!( + matches!(&replayed.steps["Preconditions"].content[0], StepContent::Prose { text } if text == "Updated via replay") + ); + } } diff --git a/src-tauri/src/commands/execution.rs b/src-tauri/src/commands/execution.rs index 0917599..461ba1f 100644 --- a/src-tauri/src/commands/execution.rs +++ b/src-tauri/src/commands/execution.rs @@ -368,7 +368,8 @@ fn event_step_and_label(event: &Event) -> (Option, Option) { Event::StepStarted { step_heading, .. } | Event::StepCompleted { step_heading, .. } | Event::StepSkipped { step_heading, .. } - | Event::CheckboxToggled { step_heading, .. } => (Some(step_heading.clone()), None), + | Event::CheckboxToggled { step_heading, .. } + | Event::StepContentUpdated { step_heading, .. } => (Some(step_heading.clone()), None), Event::InputRecorded { step_heading, label, @@ -397,6 +398,7 @@ fn event_type_string(event: &Event) -> String { Event::InputRecorded { .. } => "input_recorded", Event::NoteAdded { .. } => "note_added", Event::AttachmentAdded { .. } => "attachment_added", + Event::StepContentUpdated { .. } => "step_content_updated", Event::ExecutionRenamed { .. } => "execution_renamed", Event::EventReverted { .. } => "event_reverted", } @@ -416,6 +418,7 @@ fn event_at(event: &Event) -> String { | Event::InputRecorded { at, .. } | Event::NoteAdded { at, .. } | Event::AttachmentAdded { at, .. } + | Event::StepContentUpdated { at, .. } | Event::ExecutionRenamed { at, .. } | Event::EventReverted { at, .. } => at.to_rfc3339(), } @@ -557,6 +560,10 @@ pub enum ExecutionAction { #[ts(optional)] after_step: Option, }, + UpdateStepContent { + step_heading: String, + content: Vec, + }, AddAttachment { step_heading: String, label: String, @@ -652,6 +659,12 @@ pub fn record_action( } => exec_state .add_step(&heading, content, after_step.as_deref()) .map_err(|e| e.to_string())?, + ExecutionAction::UpdateStepContent { + step_heading, + content, + } => exec_state + .update_step_content(&step_heading, content) + .map_err(|e| e.to_string())?, ExecutionAction::AddAttachment { step_heading, label, diff --git a/src/lib/components/EditStepDialog.svelte b/src/lib/components/EditStepDialog.svelte new file mode 100644 index 0000000..faa1fd2 --- /dev/null +++ b/src/lib/components/EditStepDialog.svelte @@ -0,0 +1,184 @@ + + + + + diff --git a/src/lib/components/StepCard.svelte b/src/lib/components/StepCard.svelte index 0644b77..2c3c477 100644 --- a/src/lib/components/StepCard.svelte +++ b/src/lib/components/StepCard.svelte @@ -39,10 +39,11 @@ import "highlight.js/styles/atom-one-light.css"; - import type { StepSummary, EventHistoryEntry } from "$lib/types"; + import type { StepSummary, StepContent, EventHistoryEntry } from "$lib/types"; import { formatTimestamp } from "$lib/utils/format"; import AttachmentField from "./AttachmentField.svelte"; import CheckboxItem from "./CheckboxItem.svelte"; + import EditStepDialog from "./EditStepDialog.svelte"; import InputField from "./InputField.svelte"; import NoteEditor from "./NoteEditor.svelte"; @@ -82,6 +83,7 @@ let showSkipDialog = $state(false); let skipReason = $state(""); + let showEditDialog = $state(false); // Find the most recent revertible step-status event (complete/skip/start). let revertibleStatusEvent = $derived( @@ -174,6 +176,15 @@ step_heading: stepSummary.heading, }); } + + function updateContent(stepHeading: string, content: StepContent[]) { + onaction({ + action: "update_step_content", + step_heading: stepHeading, + content, + }); + showEditDialog = false; + }

{stepSummary.heading}

+ {#if executionActive} + + {/if} {#if stepSummary.status_at} {formatTimestamp(stepSummary.status_at)} {/if} @@ -324,6 +338,14 @@ {/if}
+{#if showEditDialog} + (showEditDialog = false)} + /> +{/if} +