feat(ui): edit quantities in the BOM part table

The BOM import dialog's Parts table now binds to the BomPartRow objects
themselves (a BindingList with Designer-defined columns) instead of a
DataTable of strings:

- Columns: Item #, File Name, Description, Material, Thickness, Qty,
  Status. Only Material, Thickness and Qty are editable, and only on
  rows that have a drawing; those rows are grey and locked. Rows that
  still need input are tinted amber.
- Qty is editable. Anything but a whole number of 1 or more is refused
  in the cell with an error icon; Esc restores the old value. A
  blank-in-BOM quantity shows a tooltip saying 1 is used.
- A thickness that is not a number above 0 is refused the same way;
  blanking it marks the row "Needs thickness".
- Every edit goes through the row's bound part, so the Groups tab
  (part count and Total Qty) and the summary follow at once, and an
  edit can never land on a different part. Rows stay in BOM order:
  the headers no longer sort, which removes the sort-then-edit defect
  where index mapping applied an edit to another part.
- Create Nests first finishes the cell being edited.

BomImportFormTests (OpenNest.WinForms.Tests) cover the columns, the
quantity edit and its group total, refused quantities, edits after
the rows are reloaded in another order, locked rows and blanked
material/thickness. They first run on the windows-desktop CI job; the
sort defect's red evidence is source review, not an executed test.

Project Memory: 5b0808d1-7353-4a34-87df-d1fbfc38f2b4
This commit is contained in:
aj committed 2026-10-05 18:01:54 -04:00
1 parent 81d3253580
commit 124d70afe7
3 files changed
+408 -84

No files matched your search

@@ -0,0 +1,188 @@
using System.Data;
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Forms;
using OpenNest.IO.Bom;
namespace OpenNest.WinForms.Tests.Forms;
public class BomImportFormTests
{
[Fact]
public void PartColumns_KeepBomOrderAndOnlyMaterialThicknessAndQtyAreEditable()
{
RunSta(() =>
{
using var form = Show(out var parts);
Assert.Equal(
new[] { "Item #", "File Name", "Description", "Material", "Thickness", "Qty", "Status" },
parts.Columns.Cast<DataGridViewColumn>().OrderBy(c => c.DisplayIndex).Select(c => c.HeaderText)
);
Assert.All(
parts.Columns.Cast<DataGridViewColumn>(),
c => Assert.Equal(DataGridViewColumnSortMode.NotSortable, c.SortMode)
);
Assert.Equal(
new[] { "colMaterial", "colThickness", "colQty" },
parts.Columns.Cast<DataGridViewColumn>().Where(c => !c.ReadOnly).Select(c => c.Name)
);
});
}
[Fact]
public void EditingQty_ChangesThatPartAndTheGroupTotal()
{
RunSta(() =>
{
using var form = Show(out var parts);
var first = Ready("PT01", qty: 2);
var second = Ready("PT02", qty: 3);
form.LoadRows(new[] { first, second });
Assert.Equal(5, GroupTotal(form, 0));
Assert.True(Edit(parts, 0, "colQty", "5"));
Assert.Equal(5, first.Qty);
Assert.Equal(3, second.Qty);
Assert.Equal(8, GroupTotal(form, 0));
Assert.Equal("Ready", parts.Rows[0].Cells["colStatus"].Value);
});
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("2.5")]
[InlineData("abc")]
[InlineData("")]
public void InvalidQty_IsRefusedAndTheOldValueKept(string text)
{
RunSta(() =>
{
using var form = Show(out var parts);
var part = Ready("PT01", qty: 2);
form.LoadRows(new[] { part });
Assert.False(Edit(parts, 0, "colQty", text));
Assert.True(parts.IsCurrentCellInEditMode);
Assert.False(string.IsNullOrEmpty(parts.CurrentCell.ErrorText));
parts.CancelEdit();
Assert.True(parts.EndEdit());
Assert.Equal(2, part.Qty);
Assert.Equal(2, GroupTotal(form, 0));
});
}
[Fact]
public void Edits_ChangeThePartShownInThatRow_AfterTheRowsAreReloadedInAnotherOrder()
{
RunSta(() =>
{
using var form = Show(out var parts);
var a = Ready("PT01", qty: 1);
var b = Ready("PT02", qty: 1);
var c = Ready("PT03", qty: 1);
form.LoadRows(new[] { a, b, c });
Assert.True(Edit(parts, 2, "colMaterial", "Aluminum"));
Assert.Equal("Aluminum", c.Material);
Assert.Equal("Stainless", a.Material);
form.LoadRows(new[] { c, a, b });
Assert.Same(c, parts.Rows[0].DataBoundItem);
Assert.True(Edit(parts, 0, "colQty", "9"));
Assert.Equal(9, c.Qty);
Assert.Equal(1, a.Qty);
});
}
[Fact]
public void RowsWithoutADrawing_CannotEnterEditMode()
{
RunSta(() =>
{
using var form = Show(out var parts);
var noDrawing = Ready("PT01", qty: 1);
noDrawing.DxfPath = null;
form.LoadRows(new[] { noDrawing, Ready("PT02", qty: 1) });
parts.CurrentCell = parts.Rows[0].Cells["colQty"];
Assert.False(parts.BeginEdit(selectAll: true));
Assert.False(parts.IsCurrentCellInEditMode);
parts.CurrentCell = parts.Rows[1].Cells["colQty"];
Assert.True(parts.BeginEdit(selectAll: true));
parts.CancelEdit();
});
}
[Fact]
public void BlankingMaterialOrThickness_MarksTheRowAndDropsItFromTheGroups()
{
RunSta(() =>
{
using var form = Show(out var parts);
var first = Ready("PT01", qty: 1);
var second = Ready("PT02", qty: 1);
form.LoadRows(new[] { first, second });
Assert.True(Edit(parts, 0, "colMaterial", " "));
Assert.Null(first.Material);
Assert.Equal("Needs material", parts.Rows[0].Cells["colStatus"].Value);
Assert.False(Edit(parts, 1, "colThickness", "abc"));
parts.CancelEdit();
Assert.True(parts.EndEdit());
Assert.Equal(0.25, second.Thickness);
Assert.True(Edit(parts, 1, "colThickness", ""));
Assert.Null(second.Thickness);
Assert.Equal("Needs thickness", parts.Rows[1].Cells["colStatus"].Value);
Assert.Empty(GroupsTable(form).Rows);
Assert.Equal("0 ready, 1 needs a material, 1 needs a thickness", Field<Label>(form, "lblSummary").Text);
});
}
private static BomImportForm Show(out DataGridView parts)
{
var form = new BomImportForm();
form.Show();
parts = Field<DataGridView>(form, "dgvParts");
return form;
}
private static bool Edit(DataGridView grid, int row, string column, string text)
{
grid.CurrentCell = grid.Rows[row].Cells[column];
Assert.True(grid.BeginEdit(selectAll: true));
grid.EditingControl!.Text = text;
return grid.EndEdit();
}
private static DataTable GroupsTable(BomImportForm form) =>
Assert.IsType<DataTable>(Field<DataGridView>(form, "dgvGroups").DataSource);
private static int GroupTotal(BomImportForm form, int group) =>
(int)GroupsTable(form).Rows[group]["Total Qty"];
private static BomPartRow Ready(string fileName, int qty) =>
new()
{
FileName = fileName,
DxfPath = $@"C:\drawings\{fileName}.dxf",
Material = "Stainless",
Thickness = 0.25,
Qty = qty,
};
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "The BOM import dialog test did not complete.");
}
+89
View File
@@ -15,6 +15,8 @@ namespace OpenNest.Forms
private void InitializeComponent()
{
System.Windows.Forms.DataGridViewCellStyle thicknessCellStyle = new System.Windows.Forms.DataGridViewCellStyle();
System.Windows.Forms.DataGridViewCellStyle qtyCellStyle = new System.Windows.Forms.DataGridViewCellStyle();
grpInput = new System.Windows.Forms.GroupBox();
tbl = new System.Windows.Forms.TableLayoutPanel();
lblJobName = new System.Windows.Forms.Label();
@@ -34,6 +36,13 @@ namespace OpenNest.Forms
tabControl = new System.Windows.Forms.TabControl();
tabParts = new System.Windows.Forms.TabPage();
dgvParts = new System.Windows.Forms.DataGridView();
colItemNum = new System.Windows.Forms.DataGridViewTextBoxColumn();
colFileName = new System.Windows.Forms.DataGridViewTextBoxColumn();
colDescription = new System.Windows.Forms.DataGridViewTextBoxColumn();
colMaterial = new System.Windows.Forms.DataGridViewTextBoxColumn();
colThickness = new System.Windows.Forms.DataGridViewTextBoxColumn();
colQty = new System.Windows.Forms.DataGridViewTextBoxColumn();
colStatus = new System.Windows.Forms.DataGridViewTextBoxColumn();
tabGroups = new System.Windows.Forms.TabPage();
dgvGroups = new System.Windows.Forms.DataGridView();
pnlBottom = new System.Windows.Forms.Panel();
@@ -265,11 +274,84 @@ namespace OpenNest.Forms
dgvParts.BackgroundColor = System.Drawing.SystemColors.Window;
dgvParts.BorderStyle = System.Windows.Forms.BorderStyle.None;
dgvParts.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgvParts.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { colItemNum, colFileName, colDescription, colMaterial, colThickness, colQty, colStatus });
dgvParts.Dock = System.Windows.Forms.DockStyle.Fill;
dgvParts.Name = "dgvParts";
dgvParts.RowHeadersVisible = false;
dgvParts.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
dgvParts.TabIndex = 0;
dgvParts.CellBeginEdit += DgvParts_CellBeginEdit;
dgvParts.CellEndEdit += DgvParts_CellEndEdit;
dgvParts.CellFormatting += DgvParts_CellFormatting;
dgvParts.CellParsing += DgvParts_CellParsing;
dgvParts.CellToolTipTextNeeded += DgvParts_CellToolTipTextNeeded;
dgvParts.CellValidating += DgvParts_CellValidating;
dgvParts.DataError += DgvParts_DataError;
//
// colItemNum
//
colItemNum.DataPropertyName = "ItemNum";
colItemNum.FillWeight = 40F;
colItemNum.HeaderText = "Item #";
colItemNum.Name = "colItemNum";
colItemNum.ReadOnly = true;
colItemNum.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colFileName
//
colFileName.DataPropertyName = "FileName";
colFileName.FillWeight = 120F;
colFileName.HeaderText = "File Name";
colFileName.Name = "colFileName";
colFileName.ReadOnly = true;
colFileName.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colDescription
//
colDescription.DataPropertyName = "Description";
colDescription.FillWeight = 150F;
colDescription.HeaderText = "Description";
colDescription.Name = "colDescription";
colDescription.ReadOnly = true;
colDescription.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colMaterial
//
colMaterial.DataPropertyName = "Material";
colMaterial.FillWeight = 110F;
colMaterial.HeaderText = "Material";
colMaterial.Name = "colMaterial";
colMaterial.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colThickness
//
thicknessCellStyle.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight;
thicknessCellStyle.Format = "0.####";
colThickness.DataPropertyName = "Thickness";
colThickness.DefaultCellStyle = thicknessCellStyle;
colThickness.FillWeight = 70F;
colThickness.HeaderText = "Thickness";
colThickness.Name = "colThickness";
colThickness.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colQty
//
qtyCellStyle.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight;
colQty.DataPropertyName = "Qty";
colQty.DefaultCellStyle = qtyCellStyle;
colQty.FillWeight = 50F;
colQty.HeaderText = "Qty";
colQty.Name = "colQty";
colQty.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// colStatus
//
colStatus.DataPropertyName = "StatusText";
colStatus.FillWeight = 100F;
colStatus.HeaderText = "Status";
colStatus.Name = "colStatus";
colStatus.ReadOnly = true;
colStatus.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// tabGroups
//
@@ -383,6 +465,13 @@ namespace OpenNest.Forms
private System.Windows.Forms.TabControl tabControl;
private System.Windows.Forms.TabPage tabParts;
private System.Windows.Forms.DataGridView dgvParts;
private System.Windows.Forms.DataGridViewTextBoxColumn colItemNum;
private System.Windows.Forms.DataGridViewTextBoxColumn colFileName;
private System.Windows.Forms.DataGridViewTextBoxColumn colDescription;
private System.Windows.Forms.DataGridViewTextBoxColumn colMaterial;
private System.Windows.Forms.DataGridViewTextBoxColumn colThickness;
private System.Windows.Forms.DataGridViewTextBoxColumn colQty;
private System.Windows.Forms.DataGridViewTextBoxColumn colStatus;
private System.Windows.Forms.TabPage tabGroups;
private System.Windows.Forms.DataGridView dgvGroups;
private System.Windows.Forms.Panel pnlBottom;
+131 -84
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.IO;
@@ -12,9 +13,10 @@ namespace OpenNest.Forms
{
public partial class BomImportForm : Form
{
private List<BomPartRow> _parts;
private static readonly Color NeedsInputBackColor = Color.FromArgb(255, 240, 200);
private readonly BindingList<BomPartRow> _parts;
private Dictionary<string, BomGroupPlateSettings> _groupSettings;
private bool _suppressRegroup;
private NestDefaults _defaults;
public Form MdiParentForm { get; set; }
@@ -22,7 +24,10 @@ namespace OpenNest.Forms
public BomImportForm()
{
InitializeComponent();
_parts = new List<BomPartRow>();
_parts = new BindingList<BomPartRow>();
_parts.ListChanged += Parts_ListChanged;
dgvParts.AutoGenerateColumns = false;
dgvParts.DataSource = _parts;
_groupSettings = new Dictionary<string, BomGroupPlateSettings>();
_defaults = MainForm.LoadSavedNestDefaults(out _);
ApplyDefaults();
@@ -99,11 +104,7 @@ namespace OpenNest.Forms
using (var reader = new BomReader(txtBomFile.Text))
items = reader.GetItems();
_parts = BomImportRows.Build(items, txtDxfFolder.Text);
_groupSettings.Clear();
PopulatePartsGrid();
RebuildGroups();
UpdateSummary();
LoadRows(BomImportRows.Build(items, txtDxfFolder.Text));
btnCreateNests.Enabled = true;
tabControl.SelectedTab = tabParts;
}
@@ -122,87 +123,133 @@ namespace OpenNest.Forms
#region Parts Tab
private void PopulatePartsGrid()
/// <summary>
/// Shows <paramref name="rows"/> in the Parts table, in their order, and
/// rebuilds the groups and summary from them.
/// </summary>
internal void LoadRows(IEnumerable<BomPartRow> rows)
{
_suppressRegroup = true;
var table = new DataTable();
table.Columns.Add("Item #", typeof(string));
table.Columns.Add("File Name", typeof(string));
table.Columns.Add("Qty", typeof(string));
table.Columns.Add("Description", typeof(string));
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Thickness", typeof(string));
table.Columns.Add("Status", typeof(string));
foreach (var part in _parts)
{
table.Rows.Add(
part.ItemNum?.ToString() ?? "",
part.FileName ?? "",
part.Qty?.ToString() ?? "",
part.Description ?? "",
part.Material ?? "",
part.Thickness?.ToString("0.####") ?? "",
part.StatusText
);
}
dgvParts.DataSource = table;
// Make non-editable columns read-only
foreach (DataGridViewColumn col in dgvParts.Columns)
{
if (col.Name != "Material" && col.Name != "Thickness")
col.ReadOnly = true;
}
// Style rows by status
for (var i = 0; i < _parts.Count; i++)
{
if (!_parts[i].IsEditable)
{
dgvParts.Rows[i].ReadOnly = true;
dgvParts.Rows[i].DefaultCellStyle.ForeColor = Color.Gray;
}
}
dgvParts.CellValueChanged -= DgvParts_CellValueChanged;
dgvParts.CellValueChanged += DgvParts_CellValueChanged;
_suppressRegroup = false;
}
private void DgvParts_CellValueChanged(object sender, DataGridViewCellEventArgs e)
{
if (_suppressRegroup || e.RowIndex < 0)
return;
var colName = dgvParts.Columns[e.ColumnIndex].Name;
if (colName != "Material" && colName != "Thickness")
return;
var part = _parts[e.RowIndex];
if (!part.IsEditable)
return;
if (colName == "Material")
part.Material = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
if (colName == "Thickness")
{
var text = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
part.Thickness = double.TryParse(text, out var t) ? t : (double?)null;
}
_suppressRegroup = true;
dgvParts.Rows[e.RowIndex].Cells["Status"].Value = part.StatusText;
_suppressRegroup = false;
_groupSettings.Clear();
_parts.RaiseListChangedEvents = false;
_parts.Clear();
foreach (var row in rows)
_parts.Add(row);
_parts.RaiseListChangedEvents = true;
_parts.ResetBindings();
RebuildGroups();
UpdateSummary();
}
private BomPartRow PartAt(int rowIndex) =>
rowIndex >= 0 && rowIndex < dgvParts.Rows.Count
? dgvParts.Rows[rowIndex].DataBoundItem as BomPartRow
: null;
private void Parts_ListChanged(object sender, ListChangedEventArgs e)
{
if (e.ListChangedType != ListChangedType.ItemChanged)
return;
switch (e.PropertyDescriptor?.Name)
{
case nameof(BomPartRow.Material):
case nameof(BomPartRow.Thickness):
case nameof(BomPartRow.Qty):
RebuildGroups();
UpdateSummary();
break;
}
}
private void DgvParts_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e)
{
// Rows without a drawing can never be imported, so they stay locked.
if (PartAt(e.RowIndex) is not { IsEditable: true })
e.Cancel = true;
}
private void DgvParts_CellValidating(object sender, DataGridViewCellValidatingEventArgs e)
{
if (!dgvParts.IsCurrentCellInEditMode)
return;
var text = e.FormattedValue?.ToString();
string error = null;
if (e.ColumnIndex == colQty.Index && !BomQuantity.TryParse(text, out _))
error = "Enter a whole number of 1 or more.";
else if (
e.ColumnIndex == colThickness.Index
&& !string.IsNullOrWhiteSpace(text)
&& !TryParseThickness(text, out _)
)
error = "Enter a thickness greater than 0, or leave it blank.";
dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].ErrorText = error ?? "";
if (error != null)
e.Cancel = true;
}
private void DgvParts_CellParsing(object sender, DataGridViewCellParsingEventArgs e)
{
var text = e.Value?.ToString();
if (e.ColumnIndex == colQty.Index && BomQuantity.TryParse(text, out var qty))
{
e.Value = qty;
e.ParsingApplied = true;
}
else if (e.ColumnIndex == colThickness.Index)
{
e.Value = TryParseThickness(text, out var thickness) ? thickness : null;
e.ParsingApplied = true;
}
else if (e.ColumnIndex == colMaterial.Index)
{
e.Value = string.IsNullOrWhiteSpace(text) ? null : text;
e.ParsingApplied = true;
}
}
private void DgvParts_CellEndEdit(object sender, DataGridViewCellEventArgs e)
{
dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].ErrorText = "";
}
private void DgvParts_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e)
{
var part = PartAt(e.RowIndex);
if (part == null)
return;
if (!part.IsEditable)
e.CellStyle.ForeColor = Color.Gray;
else if (part.Status != BomRowStatus.Ready)
e.CellStyle.BackColor = NeedsInputBackColor;
}
private void DgvParts_CellToolTipTextNeeded(
object sender,
DataGridViewCellToolTipTextNeededEventArgs e
)
{
if (e.ColumnIndex == colQty.Index && PartAt(e.RowIndex) is { QtyAssumed: true })
e.ToolTipText = "The BOM has no quantity for this part; 1 is used.";
}
private void DgvParts_DataError(object sender, DataGridViewDataErrorEventArgs e)
{
// Never show the grid's default error dialog; keep the edit open instead.
e.ThrowException = false;
e.Cancel = true;
if (e.RowIndex >= 0 && e.ColumnIndex >= 0)
dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].ErrorText = "This value cannot be used.";
}
private static bool TryParseThickness(string text, out double thickness) =>
double.TryParse(text, out thickness) && double.IsFinite(thickness) && thickness > 0;
#endregion
#region Groups Tab
@@ -319,7 +366,7 @@ namespace OpenNest.Forms
private void CreateNests_Click(object sender, EventArgs e)
{
if (_parts == null || _parts.Count == 0)
if (_parts.Count == 0 || !dgvParts.EndEdit())
return;
// Save latest group edits