feat(browser): edit nest information without opening the nest

This commit is contained in:
aj committed 2026-10-16 00:03:32 -04:00
1 parent d028d82f0a
commit 9f0d4f37ce
3 files changed
+210 -7

No files matched your search

@@ -145,6 +145,80 @@ 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(() =>
{
var record = Record("Current name");
record.Revision = 3;
record.Comments = "Current metadata notes";
record.Status = NestStatus.ToBeCut;
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<DataGridView>(form, "nestGrid").Rows.Count == 1, "the nest page");
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);
dialog.Customer = "Updated customer";
dialog.Notes = "Updated notes";
dialog.Status = NestStatus.HasBeenCut;
dialog.MadeBy = "Operator";
dialog.MaterialName = "Steel";
dialog.Thickness = 0.25;
return accept ? DialogResult.OK : DialogResult.Cancel;
};
var button = Field<Button>(form, "infoButton");
Assert.True(button.Enabled);
Assert.Contains(Field<DataGridView>(form, "nestGrid").ContextMenuStrip!.Items.Cast<ToolStripItem>(),
item => item.Text == "Nest Information...");
button.PerformClick();
PumpUntil(() => shown && form.Enabled, "the information edit");
Assert.True(shown);
Assert.True(form.Enabled);
Assert.Equal(DialogResult.None, form.DialogResult);
Assert.Equal(Guid.Empty, form.SelectedId);
Assert.Equal(accept ? 1 : 0, repository.UpdateAttempts);
if (accept && !conflict)
{
Assert.Equal(3, repository.ExpectedRevision);
Assert.Equal(4, record.Revision);
Assert.Equal("Updated customer", record.Customer);
Assert.Equal("Updated notes", record.Comments);
using var stream = new MemoryStream(repository.Files[record.Id]);
var nest = new NestReader(stream).Read();
Assert.Equal("Updated customer", nest.Customer);
Assert.Equal("Updated notes", nest.Notes);
Assert.Equal(NestStatus.HasBeenCut, nest.Status);
Assert.Equal("Operator", nest.MadeBy);
Assert.Equal("Steel", nest.Material.Name);
Assert.Equal(0.25, nest.Thickness);
Assert.Single(nest.Plates);
Assert.Equal(2, nest.Plates[0].Quantity);
Assert.Equal(3, nest.Plates[0].Parts.Count);
Assert.Equal("Updated customer", Field<DataGridView>(form, "nestGrid").Rows[0].Cells["Customer"].Value);
}
else
{
Assert.Equal(original, repository.Files[record.Id]);
Assert.Equal(3, record.Revision);
if (conflict)
Assert.Contains("409", Field<Label>(form, "summaryLabel").Text);
}
}, TestTimeout, "The information edit did not complete.");
}
private static NestRecord Record(string name) =>
new() { Id = Guid.NewGuid(), Name = name, SavedAt = DateTime.Now };
@@ -203,8 +277,39 @@ public class SavedNestsFormTests
public FakeRepository(params NestRecord[] records) => _records = records;
public bool Conflict { get; set; }
public int UpdateAttempts { get; private set; }
public int ExpectedRevision { get; private set; }
public Dictionary<Guid, byte[]> Files { get; } = new();
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) =>
GetFileAsync(id, cancellationToken);
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestRevisionPage());
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] bytes, NestRecord metadata, int expectedRevision,
CancellationToken cancellationToken = default)
{
UpdateAttempts++;
ExpectedRevision = expectedRevision;
if (Conflict) throw new IOException("409: changed on another PC");
var current = _records.Single(r => r.Id == id);
Assert.Equal(current.Revision, expectedRevision);
current.Revision++;
current.Name = metadata.Name;
current.Customer = metadata.Customer;
current.Comments = metadata.Comments;
current.Material = metadata.Material;
current.Thickness = metadata.Thickness;
current.Status = metadata.Status;
current.MadeBy = metadata.MadeBy;
current.DateModified = metadata.DateModified;
current.FileSize = bytes.Length;
Files[id] = bytes;
return Task.FromResult(current);
}
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestPage
{
@@ -221,7 +326,7 @@ public class SavedNestsFormTests
throw new NotSupportedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
Task.FromResult(_records.SingleOrDefault(r => r.Id == id));
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
+93 -6
View File
@@ -18,7 +18,7 @@ namespace OpenNest.Forms;
/// server one bounded page at a time; filtering, sorting and paging run on the server
/// through <see cref="NestBrowseSession"/>. The lower tabs show plates and drawings
/// from <see cref="NestDetailsSession"/> and on-demand version history beside a plate
/// preview. This form renders the selected nest without editing its archive.
/// preview. Nest Information edits a detached snapshot through guarded database saves.
/// </summary>
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<EditNestInfoForm, DialogResult> ShowNestInfoDialog { get; set; }
private IReadOnlyList<Plate> previewPlates = Array.Empty<Plate>();
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,76 @@ public sealed class SavedNestsForm : Form
}
}
/// <summary>Edits a detached current snapshot without opening an MDI document.</summary>
internal async Task EditSelectedInfoAsync()
{
if (editingInfo || deleting || SelectedNestId() is not Guid id)
return;
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.");
using var source = new MemoryStream(snapshot.Archive);
var nest = new NestReader(source).Read();
// Metadata-only revisions can be newer than the fields inside the archive.
nest.Name = snapshot.Metadata.Name;
nest.Customer = snapshot.Metadata.Customer;
nest.Notes = snapshot.Metadata.Comments;
nest.Status = snapshot.Metadata.Status;
nest.MadeBy = snapshot.Metadata.MadeBy;
nest.DateLastModified = snapshot.Metadata.DateModified;
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;
using var output = new MemoryStream();
new NestWriter(nest).Write(output);
var bytes = output.ToArray();
var save = new NestSaveSession();
save.Bind(id, snapshot.ServerUrl, snapshot.Metadata.Revision);
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;
UpdateCommands();
}
}
}
private async Task DeleteSelectedAsync()
{
if (deleting || SelectedNestId() is not Guid id)
+11
View File
@@ -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...**