mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
feat(browser): edit nest information without opening the nest
This commit is contained in:
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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...**
|
||||
|
||||
Reference in new issue
Block a user