diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs index cce90482..8378e335 100644 --- a/OpenNest.Data/NestRecordFactory.cs +++ b/OpenNest.Data/NestRecordFactory.cs @@ -9,6 +9,22 @@ namespace OpenNest.Data; /// public static class NestRecordFactory { + /// Overlays authoritative current metadata on an archived nest before editing. + public static void ApplyMetadata(Nest nest, NestRecord record) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(record); + nest.Name = record.Name; + nest.Customer = record.Customer; + nest.Notes = record.Comments; + nest.Status = record.Status; + nest.MadeBy = record.MadeBy; + nest.DateLastModified = record.DateModified; + nest.DateCreated = record.DateCreated; + nest.Material = NestMaterialSelection.Apply(nest.Material, record.Material); + nest.Thickness = record.Thickness; + } + /// /// Builds a record for uploading . is the /// client-tracked id: for a first save (the server diff --git a/OpenNest.Tests/Data/NestRecordFactoryTests.cs b/OpenNest.Tests/Data/NestRecordFactoryTests.cs index 6003743f..1a5b0d68 100644 --- a/OpenNest.Tests/Data/NestRecordFactoryTests.cs +++ b/OpenNest.Tests/Data/NestRecordFactoryTests.cs @@ -6,6 +6,52 @@ namespace OpenNest.Tests.Data; public class NestRecordFactoryTests { + [Theory] + [InlineData("Steel", "Aluminum", false)] + [InlineData("Aluminum", "Aluminum", true)] + public void ApplyMetadata_UsesAllCurrentFields_AndPreservesMatchingMaterialProperties( + string archiveMaterial, string currentMaterial, bool sameMaterial) + { + var nest = new Nest("Old archive") + { + Material = new Material(archiveMaterial, "Test grade", 0.1), + Thickness = 0.125, + DateCreated = new DateTime(2025, 1, 1), + }; + var plate = nest.CreatePlate(); + var current = new NestRecord + { + Name = "Current name", + Customer = "Current customer", + Comments = "Current notes", + Material = currentMaterial, + Thickness = 0.375, + DateCreated = new DateTime(2026, 1, 1), + DateModified = new DateTime(2026, 2, 1), + Status = NestStatus.HasBeenCut, + MadeBy = "Operator", + }; + NestRecordFactory.ApplyMetadata(nest, current); + var roundTrip = NestRecordFactory.FromNest(nest, Guid.Empty, 0); + Assert.Equal(current.Material, roundTrip.Material); + Assert.Equal(current.Thickness, roundTrip.Thickness); + Assert.Equal(current.DateCreated, roundTrip.DateCreated); + Assert.Equal(current.DateModified, roundTrip.DateModified); + Assert.Equal(current.Name, roundTrip.Name); + Assert.Equal(current.Customer, roundTrip.Customer); + Assert.Equal(current.Comments, roundTrip.Comments); + Assert.Equal(current.Status, roundTrip.Status); + Assert.Equal(current.MadeBy, roundTrip.MadeBy); + if (sameMaterial) + { + Assert.Equal("Test grade", nest.Material.Grade); + Assert.Equal(0.1, nest.Material.Density); + } + else + Assert.NotEqual("Test grade", nest.Material.Grade); + Assert.Same(plate, Assert.Single(nest.Plates)); + } + [Fact] public void FromNest_CountsPlatesAndNonCutoffParts() { diff --git a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs index bacc76d8..fe3a4c5c 100644 --- a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs @@ -145,6 +145,140 @@ public class SavedNestsFormTests "The saved-nest dialog test did not complete."); } + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + public void NestInformation_SavesOnlyOnAccept_AndRefusesStaleWrites(bool accept, bool conflict) + { + StaTestThread.Run(() => + { + // PerformClick outside Application.Run needs the real UI dispatcher installed. + using var context = new WindowsFormsSynchronizationContext(); + var record = Record("Current name"); + record.Revision = 3; + record.Comments = "Current metadata notes"; + record.Status = NestStatus.ToBeCut; + record.Material = "Aluminum"; + record.Thickness = 0.375; + record.DateCreated = new DateTime(2026, 1, 1); + var repository = new FakeRepository(record) { Conflict = conflict }; + repository.Files[record.Id] = NestFile((Quantity: 2, Parts: 3)); + record.FileSize = repository.Files[record.Id].Length; + var original = repository.Files[record.Id].ToArray(); + using var form = new SavedNestsForm(repository, "http://nest-server:5000"); + form.Show(); + PumpUntil(() => Field(form, "nestGrid").Rows.Count == 1, "the nest page"); + SynchronizationContext.SetSynchronizationContext(context); + var shown = false; + form.ShowNestInfoDialog = dialog => + { + shown = true; + Assert.Equal(record.Name, dialog.NestName); + Assert.Equal(record.Comments, dialog.Notes); + Assert.Equal(NestStatus.ToBeCut, dialog.Status); + Assert.Equal("Aluminum", dialog.MaterialName); + Assert.Equal(0.375, dialog.Thickness); + Assert.Equal(new DateTime(2026, 1, 1), dialog.DateCreated); + dialog.Customer = "Updated customer"; + dialog.Notes = "Updated notes"; + dialog.Status = NestStatus.HasBeenCut; + dialog.MadeBy = "Operator"; + return accept ? DialogResult.OK : DialogResult.Cancel; + }; + var button = Field public sealed class SavedNestsForm : Form { @@ -63,6 +63,11 @@ public sealed class SavedNestsForm : Form private readonly ToolStripMenuItem openMenuItem; private readonly ToolStripMenuItem deleteMenuItem; private bool deleting; + private bool editingInfo; + private readonly Button infoButton; + private readonly ToolStripMenuItem infoMenuItem; + + internal Func ShowNestInfoDialog { get; set; } private IReadOnlyList previewPlates = Array.Empty(); private int previewIndex = -1; @@ -143,7 +148,8 @@ public sealed class SavedNestsForm : Form openMenuItem = new ToolStripMenuItem("Open", null, (_, _) => OpenSelected()) { ShortcutKeyDisplayString = "Enter" }; deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync()); var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync)); - menu.Items.AddRange(new ToolStripItem[] { openMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem }); + infoMenuItem = new ToolStripMenuItem("Nest Information...", null, async (_, _) => await EditSelectedInfoAsync()); + menu.Items.AddRange(new ToolStripItem[] { openMenuItem, infoMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem }); nestGrid.ContextMenuStrip = menu; // Details of the highlighted nest. @@ -237,7 +243,14 @@ public sealed class SavedNestsForm : Form SplitterWidth = 6, }; split.Panel1.Controls.Add(nestGrid); - split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top)); + infoButton = new Button { Text = "Nest Information...", AutoSize = true, Dock = DockStyle.Right }; + infoButton.Click += async (_, _) => await EditSelectedInfoAsync(); + var nestsHeader = new Panel { Dock = DockStyle.Top, Height = 36 }; + nestsHeader.Controls.Add(infoButton); + nestsHeader.Controls.Add(SectionLabel("Nests", DockStyle.Left)); + split.Panel1.Controls.Add(nestsHeader); + ShowNestInfoDialog = dialog => dialog.ShowDialog(this); + FormClosing += (_, e) => e.Cancel = editingInfo; split.Panel2.Controls.Add(detailsSplit); split.Panel2.Controls.Add(detailsHeader); @@ -322,6 +335,8 @@ public sealed class SavedNestsForm : Form protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { + if (editingInfo) + return true; switch (keyData) { case Keys.Enter when searchBox.Focused: @@ -492,9 +507,11 @@ public sealed class SavedNestsForm : Form private void UpdateCommands() { - var hasSelection = SelectedNestId() != null; + var hasSelection = SelectedNestId() != null && !editingInfo && !deleting; openMenuItem.Enabled = hasSelection; - deleteMenuItem.Enabled = hasSelection && !deleting; + infoButton.Enabled = hasSelection; + infoMenuItem.Enabled = hasSelection; + deleteMenuItem.Enabled = hasSelection; } private void UpdateTitle(string summary) @@ -759,7 +776,7 @@ public sealed class SavedNestsForm : Form private void OpenSelected() { - if (SelectedNestId() is not Guid id) + if (editingInfo || SelectedNestId() is not Guid id) return; SelectedId = id; DialogResult = DialogResult.OK; @@ -801,6 +818,84 @@ public sealed class SavedNestsForm : Form } } + /// Edits a detached current snapshot without opening an MDI document. + internal async Task EditSelectedInfoAsync() + { + if (editingInfo || deleting || SelectedNestId() is not Guid id) + return; + summaryLabel.Text = "Loading nest information..."; + editingInfo = true; + Enabled = false; + UpdateCommands(); + try + { + var snapshot = await NestHistorySession.ReadCurrentAsync(repository, id, serverUrl); + if (IsDisposed) return; + if (snapshot.Metadata.Revision < 1) + throw new NotSupportedException("Upgrade the nest server and refresh before editing nest information."); + var nest = await Task.Run(() => + { + using var source = new MemoryStream(snapshot.Archive); + var loaded = new NestReader(source).Read(); + // Metadata-only revisions can be newer than the archived fields. + NestRecordFactory.ApplyMetadata(loaded, snapshot.Metadata); + return loaded; + }); + if (IsDisposed) return; + using var dialog = new EditNestInfoForm(); + dialog.LoadNestInfo(nest); + if (ShowNestInfoDialog(dialog) != DialogResult.OK) + return; + // Same default propagation as Nest > Nest Info in the open editor. + using var manager = new PlateManager(nest); + manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest)); + nest.DateLastModified = DateTime.Now; + summaryLabel.Text = "Saving nest information..."; + var bytes = await Task.Run(() => + { + using var output = new MemoryStream(); + new NestWriter(nest).Write(output); + return output.ToArray(); + }); + var save = new NestSaveSession(); + save.Bind(id, snapshot.ServerUrl, snapshot.Metadata.Revision); + summaryLabel.Text = "Sending nest information..."; + var saved = await save.SaveAsync(repository, snapshot.ServerUrl, nest, bytes); + var verified = await NestHistorySession.ReadCurrentAsync(repository, id, snapshot.ServerUrl); + if (verified.Metadata.Revision != saved.Revision + || !System.Linq.Enumerable.SequenceEqual(bytes, verified.Archive)) + throw new InvalidDataException("Could not verify the saved nest information."); + if (IsDisposed) return; + // Drop archive previews and embedded history, including prefetched snapshots. + details.Dispose(); + details = new NestDetailsSession(LoadDetailsAsync); + historyView?.Dispose(); + historyView = null; + historyId = null; + ShowDetailsMessage(""); + await RunAsync(session.RefreshAsync); + foreach (DataGridViewRow row in nestGrid.Rows) + if (row.Cells[IdColumn].Value is Guid rowId && rowId == id) + nestGrid.CurrentCell = row.Cells[0]; + } + catch (Exception ex) + { + if (!IsDisposed) + summaryLabel.Text = $"Could not edit nest information: {ex.Message} Refresh and reopen Nest Information before trying again; the save may already have reached the server."; + } + finally + { + editingInfo = false; + if (!IsDisposed) + { + Enabled = true; + if (summaryLabel.Text == "Loading nest information...") + summaryLabel.Text = session.Summary; + UpdateCommands(); + } + } + } + private async Task DeleteSelectedAsync() { if (deleting || SelectedNestId() is not Guid id) diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 70fae75c..0544d980 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -28,6 +28,17 @@ The desktop app can save nests two ways: previous one. On the Versions tab, **Open read-only...** previews one snapshot without binding an editable document, and **Download selected version...** saves its exact archived bytes. + Select a nest and click **Nest Information...** above the list (or use the + row's right-click menu) to edit the same information as **Nest > Nest Info** + without opening the nest in the main editor. **OK** saves immediately to the + same database ID as a new version, then refreshes the list, preview and history; + **Cancel** makes no change. The dialog starts with the latest saved metadata. + Plate defaults follow the same rules as the open editor: populated sheets are + not resized or re-nested. This action requires a version-aware server and + refuses to overwrite a newer save from another PC. After a conflict or network + error, refresh and reopen the information dialog before trying again; a lost + reply can mean the save already reached the server. An already-open editor is + not modified; reload it before saving further changes to that same nest. A separate confirmed **Restore selected version...** creates a new current revision of the same ID and keeps all intervening versions. **Delete...** hides the record from ordinary browsing but retains every archive; **Deleted nests...**