feat(desktop): open Edit Drawing from everywhere and warn before duplicates

Builds on the library's Edit Drawing (details as a new revision):
- warns before saving details that would duplicate another active library
  drawing (D4), naming them; a failed lookup also asks;
- File > Drawing Library, a nest-less mode of the library browser that lists
  every active drawing and edits on double-click;
- the set editor's member grid (double-click, right-click, button);
- the release dialog: Fix Drawings on a group refused for missing material or
  thickness or duplicate names, so those refusals are no longer a dead end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-24 15:33:12 -04:00
1 parent 48863d51c8
commit 0d1dade063
23 files changed
+1073 -104

No files matched your search

+5 -5
View File
@@ -11,17 +11,17 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPa
{
private readonly IDrawingLibrary _library;
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
/// <param name="nest">The nest to filter to, or null to list every active drawing (File &gt; Drawing Library).</param>
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest? nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(nest);
_library = library;
MaterialName = (nest.Material?.Name ?? "").Trim();
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
MaterialName = (nest?.Material?.Name ?? "").Trim();
MaterialGrade = (nest?.Material?.Grade ?? "").Trim();
// A nest without a thickness has nothing to match; it filters by material only.
Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
Thickness = nest != null && double.IsFinite(nest.Thickness) && nest.Thickness > 0
? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
: null;
}
@@ -77,6 +77,32 @@ public sealed class DrawingLibraryEditSession
SameDetails(Build(details, notes, settings), Listed)
&& (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
/// <summary>
/// True when the edit changes the details that identify a library drawing (D4: customer,
/// name, material, grade, thickness and cut type, normalized as duplicates are matched).
/// </summary>
public bool ChangesIdentity(DrawingLibraryDetails details) =>
!DrawingLibraryDuplicates.SameDuplicateKey(BuildDetails(details), Listed);
/// <summary>
/// The other active library drawings the edited details would duplicate (D4), newest
/// first. Empty when the edit keeps this drawing's identity, so drawings that already
/// shared it are not reported on every save.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> FindCollisionsAsync(DrawingLibraryDetails details,
CancellationToken cancellationToken = default)
{
var record = BuildDetails(details);
if (!ChangesIdentity(details) || DrawingLibraryRules.GetMetadataError(record) != null)
return Array.Empty<DrawingLibraryRecord>();
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, record, cancellationToken);
return matches.Where(m => m.Id != Listed.Id).ToList();
}
// The record with the edited details and the listed notes and settings, for identity checks.
private DrawingLibraryRecord BuildDetails(DrawingLibraryDetails details) =>
Build(details, Listed.Notes, Listed.Settings ?? new DrawingNestingSettings());
/// <summary>Saves the edit as the next revision; returns the stored record.</summary>
public async Task<DrawingLibraryRecord> SaveAsync(DrawingLibraryDetails details, string notes,
DrawingNestingSettings settings, CancellationToken cancellationToken = default)
+46 -36
View File
@@ -85,6 +85,51 @@ public sealed class DrawingLibraryDuplicates
/// <summary>True when these duplicates were found for exactly <paramref name="metadata"/>'s matching details.</summary>
public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata);
/// <summary>
/// The active library drawings whose duplicate key equals <paramref name="metadata"/>'s,
/// newest first, reading every page the server filters down to; none when it is invalid.
/// </summary>
internal static async Task<List<DrawingLibraryRecord>> FindMatchesAsync(IDrawingLibrary library,
DrawingLibraryRecord metadata, CancellationToken cancellationToken)
{
var matches = new List<DrawingLibraryRecord>();
if (string.IsNullOrWhiteSpace(metadata.Name) || DrawingLibraryRules.GetMetadataError(metadata) != null)
return matches;
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(metadata.Name),
Customer = metadata.Customer,
MaterialName = metadata.MaterialName,
MaterialGrade = metadata.MaterialGrade,
Thickness = metadata.Thickness,
CutType = metadata.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && SameDuplicateKey(r, metadata)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
return matches;
if (offset >= DrawingLibrarySaveSession.MaxScannedDrawings)
throw new IOException($"More than {DrawingLibrarySaveSession.MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
internal static bool SameDuplicateKey(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
SharedListNames.Key(a.Customer) == SharedListNames.Key(b.Customer)
&& SharedListNames.Key(a.Name) == SharedListNames.Key(b.Name)
@@ -169,37 +214,7 @@ public sealed class DrawingLibrarySaveSession
{
ArgumentNullException.ThrowIfNull(metadata);
var snapshot = Copy(metadata);
var matches = new List<DrawingLibraryRecord>();
if (!string.IsNullOrWhiteSpace(snapshot.Name) && DrawingLibraryRules.GetMetadataError(snapshot) == null)
{
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await _library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(snapshot.Name),
Customer = snapshot.Customer,
MaterialName = snapshot.MaterialName,
MaterialGrade = snapshot.MaterialGrade,
Thickness = snapshot.Thickness,
CutType = snapshot.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && DrawingLibraryDuplicates.SameDuplicateKey(r, snapshot)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
break;
if (offset >= MaxScannedDrawings)
throw new IOException($"More than {MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, snapshot, cancellationToken);
DrawingLibraryRecord? linked = null;
if (Drawing.Library is { } link && link.ServerKey == ServerKey
@@ -336,11 +351,6 @@ public sealed class DrawingLibrarySaveSession
return new DrawingLibrarySaveResult(saved, outcome, recoveredEarlierSave);
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
+15
View File
@@ -131,6 +131,21 @@ public sealed class DrawingSetEditSession
_members[index].Quantity = quantity;
}
/// <summary>
/// Shows <paramref name="drawing"/>, a newer revision of a member's library drawing (for
/// example one just edited), on that member. Members are saved by id, so the set itself is
/// not changed; false when no member lists the drawing or it already shows that revision.
/// </summary>
public bool UpdateDrawing(DrawingLibraryRecord drawing)
{
ArgumentNullException.ThrowIfNull(drawing);
var member = _members.FirstOrDefault(m => m.DrawingId == drawing.Id);
if (member == null || (member.Drawing != null && member.Drawing.Revision >= drawing.Revision))
return false;
member.Drawing = drawing;
return true;
}
/// <summary>The record a save sends.</summary>
public DrawingSetRecord Build() => new()
{
+17 -4
View File
@@ -64,7 +64,8 @@ public sealed class SetReleaseItem
public sealed class SetReleaseGroup
{
internal SetReleaseGroup(string key, DrawingLibraryRecord first, bool unassigned,
IReadOnlyList<SetReleaseItem> items, IReadOnlyList<string> warnings, string refusal)
IReadOnlyList<SetReleaseItem> items, IReadOnlyList<string> warnings, string refusal,
IReadOnlyList<SetReleaseItem> refusedItems)
{
Key = key;
MaterialName = (first.MaterialName ?? "").Trim();
@@ -76,6 +77,7 @@ public sealed class SetReleaseGroup
Items = items;
Warnings = warnings;
Refusal = refusal;
RefusedItems = refusedItems;
}
/// <summary>Identifies the group across rebuilds of the plan (for example after the multiplier changes).</summary>
@@ -112,6 +114,12 @@ public sealed class SetReleaseGroup
public string Refusal { get; }
public bool CanRelease => Refusal.Length == 0;
/// <summary>
/// The items whose library drawings must be edited before the group can be released (all
/// of an unassigned group, or those sharing a name), in set order; empty when it can be.
/// </summary>
public IReadOnlyList<SetReleaseItem> RefusedItems { get; }
}
/// <summary>
@@ -312,21 +320,26 @@ public sealed class SetReleasePlan
.ToList();
var refusal = "";
IReadOnlyList<SetReleaseItem> refused = Array.Empty<SetReleaseItem>();
if (unassigned)
{
refusal = $"{Names(items)} {(items.Count == 1 ? "has" : "have")} no material or thickness. "
+ "Set them on the library drawings, then release again.";
+ "Set them on the library drawings (Edit Drawing), then release again.";
refused = items;
}
else
{
// Matches LibraryDrawingInsert, which refuses a second drawing with the same name.
var duplicate = items.GroupBy(i => i.Drawing.Name, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
if (duplicate != null)
{
refusal = $"{duplicate.Count()} drawings in this group are named '{duplicate.Key}'. "
+ "A nest keys drawings by name, so rename all but one in the library first.";
+ "A nest keys drawings by name, so rename all but one in the library (Edit Drawing) first.";
refused = duplicate.ToList();
}
}
return new SetReleaseGroup(key, items[0].Drawing, unassigned, items, warnings, refusal);
return new SetReleaseGroup(key, items[0].Drawing, unassigned, items, warnings, refusal, refused);
}
private static string Names(IReadOnlyList<SetReleaseItem> items)
@@ -180,6 +180,50 @@ public sealed class DrawingLibraryEndToEndTests
Assert.Equal(moved, await library.GetPackageAsync(current.Id));
}
[Fact]
public async Task EditSession_ChangesTheDetails_KeepsThePackage_AndSetsReleaseTheNewRevision()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString());
using var sets = new RemoteDrawingSetStore(client, client.BaseAddress!.ToString());
var bracket = SyntheticDrawing("Synthetic bracket", 2);
var missing = Metadata(bracket, CutType.Laser, 0);
missing.MaterialName = "";
var package = DrawingPackage.Write(bracket, Units.Inches);
var stored = await library.UploadAsync(missing, package);
var twin = Metadata(SyntheticDrawing("Synthetic plate", 5), CutType.Laser, 0.25);
await library.UploadAsync(twin, DrawingPackage.Write(SyntheticDrawing("Synthetic plate", 5), Units.Inches));
var set = await sets.CreateAsync(new DrawingSetRecord
{
Id = Guid.NewGuid(),
Name = "Synthetic kit",
Members = new[] { new DrawingSetMember { DrawingId = stored.Id, Quantity = 2 } },
});
var before = SetReleasePlan.Build(set, 1, includeArchived: false);
var session = new DrawingLibraryEditSession(library, stored, "Synthetic operator");
var fixedDetails = session.Details with { MaterialName = "Synthetic Steel", Thickness = 0.25 };
var colliding = session.Details with { Name = "Synthetic plate", MaterialName = "Synthetic Steel", Thickness = 0.25 };
var none = await session.FindCollisionsAsync(fixedDetails);
var collisions = await session.FindCollisionsAsync(colliding);
var settings = stored.Settings ?? new DrawingNestingSettings();
var saved = await session.SaveAsync(fixedDetails, stored.Notes, settings);
var stale = await Assert.ThrowsAsync<DrawingLibraryConflictException>(() => session.SaveAsync(fixedDetails, "Late", settings));
var after = SetReleasePlan.Build((await sets.GetAsync(set.Id))!, 1, includeArchived: false);
Assert.Empty(none);
Assert.Equal(twin.Id, Assert.Single(collisions).Id);
Assert.Equal((2, "Synthetic Steel", 0.25, Units.Inches), (saved.Revision, saved.MaterialName, saved.Thickness, saved.Units));
Assert.Equal(stored.PackageSha256, saved.PackageSha256);
Assert.Equal(package, await library.GetRevisionPackageAsync(stored.Id, 2));
Assert.Equal(2, stale.Current.Revision);
Assert.True(Assert.Single(before.Groups).IsUnassigned);
var group = Assert.Single(after.Groups);
Assert.True(group.CanRelease);
Assert.Equal(2, Assert.Single(group.Items).Drawing.Revision);
}
[Fact]
public async Task ArchiveRestoreAndMetadata_ThroughTheClient()
{
@@ -59,6 +59,19 @@ public class DrawingLibraryBrowseSessionTests
Assert.False(session.IsFilteredToNest);
}
[Fact]
public async Task WithoutANest_ListsEveryDrawing()
{
var library = new PagedLibrary(3);
using var session = new DrawingLibraryBrowseSession(library, null);
await session.RefreshAsync();
var query = Assert.Single(library.Queries);
Assert.Equal(("", "", (double?)null), (query.MaterialName, query.MaterialGrade, query.Thickness));
Assert.False(session.IsFilteredToNest);
}
[Fact]
public async Task SearchSortAndPaging_FollowTheNestBrowseRules()
{
@@ -117,6 +117,44 @@ public class DrawingLibraryEditSessionTests
Assert.Throws<ArgumentException>(() => new DrawingLibraryEditSession(new RecordingStore(), Listed(archived: true), ""));
}
[Fact]
public void ChangesIdentity_FollowsTheDuplicateKey_NotNotesOrSettings()
{
var session = new DrawingLibraryEditSession(new RecordingStore(), Listed(), "");
Assert.False(session.ChangesIdentity(session.Details));
// Customer, name, material and grade compare by normalized key, as duplicates are matched.
Assert.False(session.ChangesIdentity(session.Details with { Name = "test SQUARE", MaterialName = "synthetic steel" }));
Assert.True(session.ChangesIdentity(session.Details with { Thickness = 0.1875 }));
Assert.True(session.ChangesIdentity(session.Details with { CutType = CutType.Plasma }));
Assert.True(session.ChangesIdentity(session.Details with { Customer = "" }));
}
[Fact]
public async Task FindCollisionsAsync_ListsOtherActiveDrawingsWithTheNewDetails_AndSkipsTheQueryWhenUnchanged()
{
var listed = Listed();
var store = new RecordingStore();
var twin = Listed();
twin.Name = "Test square B";
var archived = Listed(archived: true);
archived.Name = "Test square B";
var near = Listed();
near.Name = "Test square B";
near.Thickness = 0.5;
store.Library.AddRange(new[] { listed, twin, archived, near });
var session = new DrawingLibraryEditSession(store, listed, "");
Assert.Empty(await session.FindCollisionsAsync(session.Details));
Assert.Empty(store.Queries);
var collisions = await session.FindCollisionsAsync(session.Details with { Name = "Test square B" });
Assert.Equal(twin.Id, Assert.Single(collisions).Id);
var query = Assert.Single(store.Queries);
Assert.Equal(("square", "Synthetic Steel", (double?)0.25), (query.Search, query.MaterialName, query.Thickness));
}
private sealed class RecordingStore : IDrawingLibraryStore
{
public List<(Guid Id, int Expected, DrawingLibraryRecord Record)> Updates { get; } = new();
@@ -145,8 +183,16 @@ public class DrawingLibraryEditSessionTests
});
}
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public List<DrawingLibraryRecord> Library { get; } = new();
public List<DrawingLibraryQuery> Queries { get; } = new();
// Every record, as a server that ignored the filters would; the session checks the key itself.
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
{
Queries.Add(query);
return Task.FromResult(new DrawingLibraryPage { Items = Library.ToArray(), Total = Library.Count, Offset = query.Offset, Limit = query.Limit });
}
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
@@ -31,6 +31,23 @@ public class DrawingSetEditSessionTests
Assert.Throws<ArgumentOutOfRangeException>(() => session.AddDrawings(new[] { Drawing("SYN-D") }, 0));
}
[Fact]
public void UpdateDrawing_ShowsANewerRevisionOnItsMember_WithoutChangingTheSet()
{
var a = Drawing("SYN-A");
var session = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "");
session.AddDrawings(new[] { a, Drawing("SYN-B") }, 2);
var before = session.Build();
var edited = new DrawingLibraryRecord { Id = a.Id, Revision = 2, Name = "SYN-A2", MaterialName = "Synthetic Aluminum", Thickness = 0.125 };
Assert.True(session.UpdateDrawing(edited));
Assert.False(session.UpdateDrawing(a));
Assert.False(session.UpdateDrawing(Drawing("SYN-C")));
Assert.Same(edited, session.Members[0].Drawing);
Assert.Equal(before.Members.Select(m => (m.DrawingId, m.Quantity)), session.Build().Members.Select(m => (m.DrawingId, m.Quantity)));
}
[Fact]
public void MoveRemoveAndQuantity_ChangeTheMemberList()
{
@@ -219,6 +219,8 @@ public class SetReleasePlanTests
var laser = plan.Groups.Single(g => g.CutType == CutType.Laser);
Assert.False(laser.CanRelease);
Assert.Contains("2 drawings in this group are named 'SYN-A'", laser.Refusal);
Assert.Equal(new[] { "SYN-A", "SYN-A" }, laser.RefusedItems.Select(i => i.Drawing.Name));
Assert.Empty(plan.Groups.Single(g => g.CutType == CutType.Plasma).RefusedItems);
// The same name in another group, or differently cased, nests separately.
Assert.True(plan.Groups.Single(g => g.CutType == CutType.Plasma).CanRelease);
}
@@ -238,6 +240,7 @@ public class SetReleasePlanTests
Assert.Equal(new[] { "SYN-NOMAT", "SYN-NOTHICK" }, unassigned.Items.Select(i => i.Drawing.Name));
Assert.False(unassigned.CanRelease);
Assert.Contains("no material or thickness", unassigned.Refusal);
Assert.Equal(unassigned.Items, unassigned.RefusedItems);
Assert.False(plan.Groups[0].IsUnassigned);
}
@@ -318,6 +318,92 @@ public class DrawingLibraryFormTests
"The drawing library details edit test did not complete.");
}
[Fact]
public void EditDrawing_WarnsBeforeDuplicatingAnotherDrawing_AndCancelSavesNothing()
{
StaTestThread.Run(
() =>
{
var library = new FakeLibrary();
var record = library.Add("SYN-606", "", "", 0, Units.Millimeters, "No material yet");
var twin = library.Add("SYN-707", "Synthetic Steel", "Grade A", 6.35, Units.Millimeters, "");
var warnings = new List<string>();
var answer = DialogResult.Cancel;
using var editor = new EditLibraryDrawingForm(new DrawingLibraryEditSession(library, record, "Synthetic operator"));
editor.ShowMessage = (text, _, _) => { warnings.Add(text); return answer; };
editor.Show();
Field<TextBox>(editor, "nameBox").Text = "SYN-707";
Field<TextBox>(editor, "materialBox").Text = "Synthetic Steel";
Field<TextBox>(editor, "gradeBox").Text = "Grade A";
Field<NumericUpDown>(editor, "thicknessBox").Value = 6.35m;
// The new details duplicate SYN-707: cancelling the warning saves nothing.
Run(editor.SaveAsync());
Assert.Contains("'SYN-707'", Assert.Single(warnings));
Assert.Empty(library.MetadataUpdates);
Assert.Null(editor.Saved);
Field<TextBox>(editor, "nameBox").Text = "SYN-606";
Field<ComboBox>(editor, "cutTypeBox").SelectedItem = "Plasma";
answer = DialogResult.OK;
Run(editor.SaveAsync());
Assert.Single(warnings);
var update = Assert.Single(library.MetadataUpdates);
Assert.Equal(("SYN-606", "Synthetic Steel", "Grade A", 6.35, Units.Millimeters, CutType.Plasma),
(update.Record.Name, update.Record.MaterialName, update.Record.MaterialGrade, update.Record.Thickness,
update.Record.Units, update.Record.CutType));
Assert.NotEqual(twin.Id, editor.Saved!.Id);
},
TestTimeout,
"The edit drawing duplicate warning test did not complete.");
}
[Fact]
public void WithoutANest_ListsEveryDrawing_HasNoAdd_AndEditsOnDoubleClick()
{
StaTestThread.Run(
() =>
{
var library = new FakeLibrary();
var record = library.Add("SYN-909", "", "", 0, Units.Inches, "");
var edited = new List<DrawingLibraryEditSession>();
using var form = new DrawingLibraryForm(library, Server, null);
form.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
form.LoadPreview = (_, _) => Task.FromResult<Drawing>(null!);
form.EditDrawing = edit =>
{
edited.Add(edit);
var save = edit.SaveAsync(edit.Details with { MaterialName = "Synthetic Steel", Thickness = 0.25 },
edit.Listed.Notes, edit.Listed.Settings ?? new DrawingNestingSettings());
return Run(save);
};
form.Show();
var grid = Field<DataGridView>(form, "drawingGrid");
PumpUntil(() => grid.Rows.Count == 1, "the library page");
var query = Assert.Single(library.Queries);
Assert.Equal(("", "", (double?)null), (query.MaterialName, query.MaterialGrade, query.Thickness));
Assert.StartsWith("Drawing Library", form.Text);
Assert.DoesNotContain(Field<TabPage>(form, "nestTab"), Field<TabControl>(form, "tabControl").TabPages.Cast<TabPage>());
Assert.False(Field<Button>(form, "addButton").Visible);
Assert.Equal("Edit Drawing...", Field<Button>(form, "editButton").Text);
Run(form.AddSelectedAsync());
Assert.Empty(library.Downloads);
typeof(DataGridView).GetMethod("OnCellDoubleClick", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(grid, new object[] { new DataGridViewCellEventArgs(0, 0) });
PumpUntil(() => Equals(grid.Rows[0].Cells["revisionColumn"].Value, record.Revision + 1), "the reloaded page");
Assert.Equal(record.Id, Assert.Single(edited).Listed.Id);
Assert.Equal("Synthetic Steel", grid.Rows[0].Cells["materialColumn"].Value);
},
TestTimeout,
"The library-only dialog test did not complete.");
}
private static Nest MatchingNest() => new("Synthetic job")
{
Units = Units.Inches,
@@ -327,6 +413,12 @@ public class DrawingLibraryFormTests
private static void Run(Task task) => PumpUntil(() => task.IsCompleted, "the add to finish");
private static T Run<T>(Task<T> task)
{
Run((Task)task);
return task.Result;
}
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
@@ -341,6 +341,53 @@ public class DrawingSetFormsTests
"The set editor test did not complete.");
}
[Fact]
public void Editor_EditDrawing_ReadsTheCurrentRecord_AndShowsWhatWasSaved_WithoutChangingTheSet()
{
StaTestThread.Run(
() =>
{
var library = new FakeLibrary();
var a = library.Add("SYN-A", "", 0);
var b = library.Add("SYN-B", "Synthetic Steel", 0.25);
var sets = new InMemoryDrawingSets();
var created = Run(sets.CreateAsync(new DrawingSetRecord
{
Id = Guid.NewGuid(),
Name = "Synthetic kit",
Members = new[] { a, b }.Select(d => new DrawingSetMember { DrawingId = d.Id, Quantity = 2, Drawing = d }).ToList(),
}));
// The library moved on since the set was read: the edit starts from the current record.
library.Replace(a.Id, r => r.Notes = "Newer notes");
var edits = new List<DrawingLibraryEditSession>();
using var editor = new EditDrawingSetForm(new DrawingSetEditSession(sets, created, "Synthetic operator"), library, Server);
editor.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
editor.EditDrawing = edit =>
{
edits.Add(edit);
return Run(edit.SaveAsync(edit.Details with { MaterialName = "Synthetic Aluminum", Thickness = 0.125 }, edit.Listed.Notes, edit.Listed.Settings ?? new DrawingNestingSettings()));
};
editor.Show();
var grid = Field<DataGridView>(editor, "memberGrid");
Assert.Contains("double-click", (string)grid.Rows[0].Cells["warningColumn"].Value);
grid.CurrentCell = grid.Rows[0].Cells["drawingColumn"];
Assert.True(Field<Button>(editor, "editDrawingButton").Enabled);
Run(editor.EditSelectedDrawingAsync());
var edit = Assert.Single(edits);
Assert.Equal((a.Id, 2, "Newer notes"), (edit.Listed.Id, edit.Listed.Revision, edit.Listed.Notes));
Assert.Equal("Synthetic Aluminum", grid.Rows[0].Cells["materialColumn"].Value);
Assert.Equal("", grid.Rows[0].Cells["warningColumn"].Value);
Assert.Contains("revision 3", Field<Label>(editor, "statusLabel").Text);
// Members are listed by id, so the set has nothing new to save.
Assert.False(Field<Button>(editor, "saveButton").Enabled);
},
TestTimeout,
"The set editor edit drawing test did not complete.");
}
[Fact]
public void History_ListsRevisionsNewestFirst_AndTheSelectedRevisionsMembers()
{
@@ -564,8 +611,45 @@ public class DrawingSetFormsTests
int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
/// <summary>Stores the next revision of a drawing, as another operator's save would.</summary>
public void Replace(Guid id, Action<DrawingLibraryRecord> edit)
{
var index = _records.FindIndex(r => r.Id == id);
var next = Copy(_records[index]);
next.Revision++;
edit(next);
_records[index] = next;
}
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
CancellationToken cancellationToken = default)
{
var index = _records.FindIndex(r => r.Id == id);
if (_records[index].Revision != expectedRevision)
throw new DrawingLibraryConflictException(_records[index]);
var saved = Copy(record);
saved.Revision = expectedRevision + 1;
saved.Units = _records[index].Units;
saved.PackageSha256 = _records[index].PackageSha256;
_records[index] = saved;
return Task.FromResult(saved);
}
private static DrawingLibraryRecord Copy(DrawingLibraryRecord r) => new()
{
Id = r.Id,
Revision = r.Revision,
Name = r.Name,
Customer = r.Customer,
MaterialName = r.MaterialName,
MaterialGrade = r.MaterialGrade,
Thickness = r.Thickness,
Units = r.Units,
CutType = r.CutType,
Notes = r.Notes,
Settings = r.Settings,
PackageSha256 = r.PackageSha256,
SavedAt = r.SavedAt,
};
}
}
@@ -114,6 +114,56 @@ public class SetReleaseFormsTests
"The release choice test did not complete.");
}
[Fact]
public void FixDrawings_EditsEachDrawingTheGroupIsRefusedFor_ThenTheGroupCanBeReleased()
{
StaTestThread.Run(
() =>
{
var library = new PackageLibrary();
var a = library.Add("SYN-A", "Synthetic Steel");
var noMaterial = library.Add("SYN-B", "");
var noThickness = library.Add("SYN-C", "Synthetic Steel", thickness: 0);
var sets = new InMemoryDrawingSets();
var set = CreateSet(sets, (a, 1), (noMaterial, 2), (noThickness, 3));
var nests = new MemoryNests();
var edits = new List<string>();
using var form = new ReleaseSetForm(sets, library, nests, Server, set, (_, _) => { });
form.ShowMessage = (_, _, _) => DialogResult.OK;
form.CreateNest = (group, _) => Task.FromResult(new Nest("N") { Units = group.Units });
form.EditDrawing = edit =>
{
edits.Add(edit.Listed.Name);
return Run(edit.SaveAsync(edit.Details with { MaterialName = "Synthetic Steel", Thickness = 0.25 }, edit.Listed.Notes, edit.Listed.Settings ?? new DrawingNestingSettings()));
};
Field<CheckBox>(form, "nestAfterReleaseCheckBox").Checked = false;
form.Show();
var grid = Field<DataGridView>(form, "groupGrid");
var fix = Field<Button>(form, "fixDrawingsButton");
var refused = grid.Rows.Cast<DataGridViewRow>().Single(r => ((SetReleaseGroup)r.Tag!).IsUnassigned);
Assert.Contains("Edit Drawing", (string)refused.Cells["statusColumn"].Value);
grid.CurrentCell = grid.Rows[0].Cells["materialColumn"];
Assert.False(fix.Enabled);
grid.CurrentCell = refused.Cells["materialColumn"];
Assert.True(fix.Enabled);
Run(form.FixSelectedGroupAsync());
Assert.Equal(new[] { "SYN-B", "SYN-C" }, edits);
var group = Assert.IsType<SetReleaseGroup>(Assert.Single(grid.Rows.Cast<DataGridViewRow>()).Tag);
Assert.True(group.CanRelease);
Assert.Equal(new[] { ("SYN-A", 1), ("SYN-B", 2), ("SYN-C", 2) }, group.Items.Select(i => (i.Drawing.Name, i.Drawing.Revision)));
Run(form.ReleaseAsync());
Assert.Single(nests.Uploaded);
Assert.Equal("Synthetic Steel", Assert.Single(Assert.Single(sets.Releases).Nests).MaterialName);
},
TestTimeout,
"The fix drawings test did not complete.");
}
[Fact]
public void ReleaseNotRecorded_CanBeRecordedAgain()
{
@@ -720,6 +770,12 @@ public class SetReleaseFormsTests
private static void Run(Task task) => PumpUntil(() => task.IsCompleted, "the operation to finish");
private static T Run<T>(Task<T> task)
{
Run((Task)task);
return task.GetAwaiter().GetResult();
}
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
@@ -741,7 +797,7 @@ public class SetReleaseFormsTests
private readonly List<DrawingLibraryRecord> _records = new();
private readonly Dictionary<(Guid, int), byte[]> _packages = new();
public DrawingLibraryRecord Add(string name, string material, bool archived = false)
public DrawingLibraryRecord Add(string name, string material, bool archived = false, double thickness = 0.25)
{
var package = DrawingPackage.Write(new RectangleShape { Name = name, Length = 4, Width = 2 }.GetDrawing(), Units.Inches);
var record = new DrawingLibraryRecord
@@ -751,7 +807,7 @@ public class SetReleaseFormsTests
Name = name,
MaterialName = material,
MaterialGrade = "Grade A",
Thickness = 0.25,
Thickness = thickness,
Units = Units.Inches,
CutType = CutType.Laser,
Archived = archived,
@@ -769,7 +825,7 @@ public class SetReleaseFormsTests
Task.FromResult(new DrawingLibraryPage { Items = _records.ToArray(), Total = _records.Count, Offset = query.Offset, Limit = query.Limit });
public Task<DrawingLibraryRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
Task.FromResult(_records.FirstOrDefault(r => r.Id == id));
public Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
@@ -781,8 +837,33 @@ public class SetReleaseFormsTests
public Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
/// <summary>Stores the next revision of the sent details with the same package.</summary>
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
CancellationToken cancellationToken = default)
{
var index = _records.FindIndex(r => r.Id == id);
var current = _records[index];
if (current.Revision != expectedRevision)
throw new DrawingLibraryConflictException(current);
var saved = new DrawingLibraryRecord
{
Id = id,
Revision = expectedRevision + 1,
Name = record.Name,
Customer = record.Customer,
MaterialName = record.MaterialName,
MaterialGrade = record.MaterialGrade,
Thickness = record.Thickness,
Units = current.Units,
CutType = record.CutType,
Notes = record.Notes,
Settings = record.Settings,
PackageSha256 = current.PackageSha256,
};
_records[index] = saved;
_packages[(id, saved.Revision)] = _packages[(id, current.Revision)];
return Task.FromResult(saved);
}
}
/// <summary>Stores uploads in memory under their client-allocated ids, and drafts a release can merge into.</summary>
+15 -1
View File
@@ -87,6 +87,8 @@ namespace OpenNest.Forms
searchTimer = new System.Windows.Forms.Timer(components);
previewTimer = new System.Windows.Forms.Timer(components);
drawingMenu = new System.Windows.Forms.ContextMenuStrip(components);
editMenuItem = new System.Windows.Forms.ToolStripMenuItem();
editSeparator = new System.Windows.Forms.ToolStripSeparator();
usedInSetsMenuItem = new System.Windows.Forms.ToolStripMenuItem();
addToSetMenuItem = new System.Windows.Forms.ToolStripMenuItem();
releaseSeparator = new System.Windows.Forms.ToolStripSeparator();
@@ -768,10 +770,20 @@ namespace OpenNest.Forms
//
// drawingMenu
//
drawingMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { usedInSetsMenuItem, addToSetMenuItem, releaseSeparator, releaseMenuItem, releasesMenuItem });
drawingMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { editMenuItem, editSeparator, usedInSetsMenuItem, addToSetMenuItem, releaseSeparator, releaseMenuItem, releasesMenuItem });
drawingMenu.Name = "drawingMenu";
drawingMenu.Opening += DrawingMenu_Opening;
//
// editMenuItem
//
editMenuItem.Name = "editMenuItem";
editMenuItem.Text = "Edit Drawing...";
editMenuItem.Click += EditButton_Click;
//
// editSeparator
//
editSeparator.Name = "editSeparator";
//
// usedInSetsMenuItem
//
usedInSetsMenuItem.Name = "usedInSetsMenuItem";
@@ -921,6 +933,8 @@ namespace OpenNest.Forms
private System.Windows.Forms.Timer searchTimer;
private System.Windows.Forms.Timer previewTimer;
private System.Windows.Forms.ContextMenuStrip drawingMenu;
private System.Windows.Forms.ToolStripMenuItem editMenuItem;
private System.Windows.Forms.ToolStripSeparator editSeparator;
private System.Windows.Forms.ToolStripMenuItem usedInSetsMenuItem;
private System.Windows.Forms.ToolStripMenuItem addToSetMenuItem;
private System.Windows.Forms.ToolStripSeparator releaseSeparator;
+43 -18
View File
@@ -14,13 +14,15 @@ using OpenNest.IO;
namespace OpenNest.Forms
{
/// <summary>
/// Nest &gt; Add from Drawing Library (Database mode). Browses the server drawing library
/// one bounded page at a time through <see cref="DrawingLibraryBrowseSession"/>, filtered
/// to the nest's material and thickness unless Show all is ticked. Add downloads the
/// listed revision's exact package, plans it with <see cref="LibraryDrawingInsert"/>,
/// shows any warning or refusal first and only then changes the nest. Edit Drawing
/// changes the selected drawing's details (name, customer, material, grade, thickness,
/// cut type), notes and nesting settings (Cut first, priority, rotation, color) on the server as a new revision with the same package. With a set
/// Nest &gt; Add from Drawing Library and File &gt; Drawing Library (Database mode). Browses
/// the server drawing library one bounded page at a time through
/// <see cref="DrawingLibraryBrowseSession"/>, filtered to the nest's material and thickness
/// unless Show all is ticked. Add downloads the listed revision's exact package, plans it
/// with <see cref="LibraryDrawingInsert"/>, shows any warning or refusal first and only then
/// changes the nest. Opened without a nest, it lists every active drawing and has no Add or
/// In This Nest tab; a double-click edits the drawing instead. Edit Drawing changes the
/// selected drawing's details, notes and nesting settings (Cut first, priority, rotation,
/// color) on the server as a new revision with the same package. With a set
/// store, the drawing grid's menu lists the sets a drawing is in, adds it to a set, lists
/// the releases that had it on its own and, once release is enabled, releases it; sets
/// themselves are browsed and released in <see cref="DrawingSetsForm"/>.
@@ -56,7 +58,7 @@ namespace OpenNest.Forms
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>Shows the settings editor and returns the saved record, or null; replaced by tests.</summary>
/// <summary>Shows Edit Drawing and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingLibraryEditSession, DrawingLibraryRecord> EditDrawing { get; set; }
/// <summary>
@@ -89,6 +91,7 @@ namespace OpenNest.Forms
/// </summary>
public bool QuantitiesChanged { get; private set; }
/// <param name="nest">The nest to add drawings to, or null to browse and edit the library alone.</param>
/// <param name="sets">Enables Used in Sets and Add to Set; null hides them.</param>
public DrawingLibraryForm(IDrawingLibraryStore library, string serverUrl, Nest nest, IDrawingSetStore sets = null)
{
@@ -100,7 +103,7 @@ namespace OpenNest.Forms
};
this.library = library ?? throw new ArgumentNullException(nameof(library));
this.serverUrl = serverUrl ?? "";
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
this.nest = nest;
this.sets = sets;
session = new DrawingLibraryBrowseSession(library, nest);
Disposed += (_, _) => session.Dispose();
@@ -148,10 +151,19 @@ namespace OpenNest.Forms
revisionColumn.Tag = DrawingSortField.Revision;
savedColumn.Tag = DrawingSortField.SavedAt;
Text = $"Add from Drawing Library — {this.serverUrl}";
Text = nest == null ? $"Drawing Library — {this.serverUrl}" : $"Add from Drawing Library — {this.serverUrl}";
if (nest == null)
{
tabControl.TabPages.Remove(nestTab);
addButton.Visible = false;
showAllCheckBox.Visible = false;
// The quantity is only for adding to a set or releasing.
quantityLabel.Visible = quantityBox.Visible = sets != null;
}
if (sets != null)
drawingGrid.ContextMenuStrip = drawingMenu;
drawingGrid.ContextMenuStrip = drawingMenu;
editSeparator.Visible = sets != null;
usedInSetsMenuItem.Visible = addToSetMenuItem.Visible = releasesMenuItem.Visible = sets != null;
showAllCheckBox.Enabled = session.IsFilteredToNest;
UpdateFilterLabel();
pager.UpdateNavigation();
@@ -354,7 +366,11 @@ namespace OpenNest.Forms
private async void DrawingGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex >= 0)
if (e.RowIndex < 0)
return;
if (nest == null)
await EditSelectedAsync();
else
await AddSelectedAsync();
}
@@ -392,6 +408,7 @@ namespace OpenNest.Forms
private void DrawingMenu_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
var record = SelectedRecord();
editMenuItem.Enabled = record is { Archived: false } && !adding;
usedInSetsMenuItem.Enabled = record != null && !adding;
addToSetMenuItem.Enabled = record != null && !adding;
releaseMenuItem.Enabled = record is { Archived: false } && !adding && ShowRelease != null;
@@ -565,8 +582,8 @@ namespace OpenNest.Forms
private async void EditButton_Click(object sender, EventArgs e) => await EditSelectedAsync();
/// <summary>
/// Edits the selected drawing's details, notes and nesting settings as a new library revision,
/// then reloads the page so the list shows the saved revision.
/// Edits the selected drawing's details, notes and nesting settings as a new library
/// revision, then reloads the page so the list shows the saved revision.
/// </summary>
internal async Task EditSelectedAsync()
{
@@ -586,7 +603,7 @@ namespace OpenNest.Forms
{
if (keyData == Keys.Enter && drawingGrid.ContainsFocus && SelectedRecord() != null)
{
_ = AddSelectedAsync();
_ = nest == null ? EditSelectedAsync() : AddSelectedAsync();
return true;
}
@@ -601,7 +618,7 @@ namespace OpenNest.Forms
internal async Task AddSelectedAsync()
{
var record = SelectedRecord();
if (record == null || adding)
if (record == null || adding || nest == null)
return;
adding = true;
@@ -706,12 +723,18 @@ namespace OpenNest.Forms
private void UpdateCommands()
{
addButton.Enabled = !adding && SelectedRecord() != null;
addButton.Enabled = !adding && nest != null && SelectedRecord() != null;
editButton.Enabled = !adding && SelectedRecord() is { Archived: false };
}
private void UpdateFilterLabel(bool showAll = false)
{
if (nest == null)
{
filterLabel.Text = "Showing every active drawing. Double-click one to edit it.";
return;
}
if (!session.IsFilteredToNest && !showAll)
{
filterLabel.Text = "This nest has no material or thickness; showing every drawing.";
@@ -738,6 +761,8 @@ namespace OpenNest.Forms
/// </summary>
private void RefreshNestTab()
{
if (nest == null)
return;
if (nestGrid.IsCurrentCellInEditMode)
nestGrid.CancelEdit();
+44 -3
View File
@@ -59,6 +59,7 @@ namespace OpenNest.Forms
quantityColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
warningColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
memberButtons = new System.Windows.Forms.FlowLayoutPanel();
editDrawingButton = new System.Windows.Forms.Button();
removeButton = new System.Windows.Forms.Button();
upButton = new System.Windows.Forms.Button();
downButton = new System.Windows.Forms.Button();
@@ -68,6 +69,9 @@ namespace OpenNest.Forms
cancelButton = new System.Windows.Forms.Button();
statusLabel = new System.Windows.Forms.Label();
searchTimer = new System.Windows.Forms.Timer(components);
memberMenu = new System.Windows.Forms.ContextMenuStrip(components);
editDrawingMenuItem = new System.Windows.Forms.ToolStripMenuItem();
removeMenuItem = new System.Windows.Forms.ToolStripMenuItem();
layoutTable.SuspendLayout();
detailsTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
@@ -418,6 +422,7 @@ namespace OpenNest.Forms
memberGrid.AllowUserToAddRows = false;
memberGrid.AllowUserToDeleteRows = false;
memberGrid.AllowUserToOrderColumns = false;
memberGrid.ContextMenuStrip = memberMenu;
memberGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { drawingColumn, materialColumn, gradeColumn, thicknessColumn, unitsColumn, cutTypeColumn, quantityColumn, warningColumn });
memberGrid.Dock = System.Windows.Forms.DockStyle.Fill;
memberGrid.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnKeystrokeOrF2;
@@ -432,6 +437,8 @@ namespace OpenNest.Forms
memberGrid.DataError += MemberGrid_DataError;
memberGrid.CellEndEdit += MemberGrid_CellEndEdit;
memberGrid.CurrentCellChanged += MemberGrid_CurrentCellChanged;
memberGrid.CellDoubleClick += MemberGrid_CellDoubleClick;
memberGrid.CellMouseDown += MemberGrid_CellMouseDown;
//
// drawingColumn
//
@@ -507,6 +514,7 @@ namespace OpenNest.Forms
// memberButtons
//
memberButtons.AutoSize = true;
memberButtons.Controls.Add(editDrawingButton);
memberButtons.Controls.Add(removeButton);
memberButtons.Controls.Add(upButton);
memberButtons.Controls.Add(downButton);
@@ -517,13 +525,24 @@ namespace OpenNest.Forms
memberButtons.TabIndex = 1;
memberButtons.WrapContents = false;
//
// editDrawingButton
//
editDrawingButton.Enabled = false;
editDrawingButton.Margin = new System.Windows.Forms.Padding(0, 0, 0, 12);
editDrawingButton.Name = "editDrawingButton";
editDrawingButton.Size = new System.Drawing.Size(100, 28);
editDrawingButton.TabIndex = 0;
editDrawingButton.Text = "Edit Drawing...";
editDrawingButton.UseVisualStyleBackColor = true;
editDrawingButton.Click += EditDrawingButton_Click;
//
// removeButton
//
removeButton.Enabled = false;
removeButton.Margin = new System.Windows.Forms.Padding(0, 0, 0, 3);
removeButton.Name = "removeButton";
removeButton.Size = new System.Drawing.Size(100, 28);
removeButton.TabIndex = 0;
removeButton.TabIndex = 1;
removeButton.Text = "Remove";
removeButton.UseVisualStyleBackColor = true;
removeButton.Click += RemoveButton_Click;
@@ -534,7 +553,7 @@ namespace OpenNest.Forms
upButton.Margin = new System.Windows.Forms.Padding(0, 12, 0, 3);
upButton.Name = "upButton";
upButton.Size = new System.Drawing.Size(100, 28);
upButton.TabIndex = 1;
upButton.TabIndex = 2;
upButton.Text = "Move Up";
upButton.UseVisualStyleBackColor = true;
upButton.Click += UpButton_Click;
@@ -545,7 +564,7 @@ namespace OpenNest.Forms
downButton.Margin = new System.Windows.Forms.Padding(0, 0, 0, 3);
downButton.Name = "downButton";
downButton.Size = new System.Drawing.Size(100, 28);
downButton.TabIndex = 2;
downButton.TabIndex = 3;
downButton.Text = "Move Down";
downButton.UseVisualStyleBackColor = true;
downButton.Click += DownButton_Click;
@@ -607,6 +626,24 @@ namespace OpenNest.Forms
searchTimer.Interval = 300;
searchTimer.Tick += SearchTimer_Tick;
//
// memberMenu
//
memberMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { editDrawingMenuItem, removeMenuItem });
memberMenu.Name = "memberMenu";
memberMenu.Opening += MemberMenu_Opening;
//
// editDrawingMenuItem
//
editDrawingMenuItem.Name = "editDrawingMenuItem";
editDrawingMenuItem.Text = "Edit Drawing...";
editDrawingMenuItem.Click += EditDrawingButton_Click;
//
// removeMenuItem
//
removeMenuItem.Name = "removeMenuItem";
removeMenuItem.Text = "Remove";
removeMenuItem.Click += RemoveButton_Click;
//
// EditDrawingSetForm
//
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
@@ -698,5 +735,9 @@ namespace OpenNest.Forms
private System.Windows.Forms.Button cancelButton;
private System.Windows.Forms.Label statusLabel;
private System.Windows.Forms.Timer searchTimer;
private System.Windows.Forms.Button editDrawingButton;
private System.Windows.Forms.ContextMenuStrip memberMenu;
private System.Windows.Forms.ToolStripMenuItem editDrawingMenuItem;
private System.Windows.Forms.ToolStripMenuItem removeMenuItem;
}
}
+113 -5
View File
@@ -18,20 +18,27 @@ namespace OpenNest.Forms
/// browsed in the left pane (every active drawing, a page at a time through
/// <see cref="DrawingLibraryBrowseSession"/>); Add to Set, a double-click or Enter adds the
/// selected drawings at the entered quantity, and a drawing already in the set is greyed
/// and not added twice. Saving adds a revision on the server, guarded by the revision the
/// set was read at.
/// and not added twice. Edit Drawing (a double-click on a member, its right-click menu or
/// the button) reads the member's current library record and edits its details there, so
/// a drawing without a material or thickness can be fixed in place. Saving adds a revision
/// on the server, guarded by the revision the set was read at.
/// </summary>
public partial class EditDrawingSetForm : Form
{
private readonly DrawingSetEditSession session;
private readonly IDrawingLibraryStore library;
private readonly DrawingLibraryBrowseSession librarySession;
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> libraryPager;
private bool loading;
private bool saving;
private bool editing;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>Shows Edit Drawing and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingLibraryEditSession, DrawingLibraryRecord> EditDrawing { get; set; }
/// <summary>The saved revision once the dialog closes with OK; otherwise null.</summary>
public DrawingSetRecord Saved { get; private set; }
@@ -39,9 +46,9 @@ namespace OpenNest.Forms
{
InitializeComponent();
this.session = session ?? throw new ArgumentNullException(nameof(session));
ArgumentNullException.ThrowIfNull(library);
this.library = library ?? throw new ArgumentNullException(nameof(library));
// A set mixes materials and thicknesses, so the library is never filtered to a nest.
librarySession = new DrawingLibraryBrowseSession(library, new Nest());
librarySession = new DrawingLibraryBrowseSession(library, null);
Disposed += (_, _) => librarySession.Dispose();
libraryPager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
librarySession, libraryGrid, librarySummaryLabel, previousButton, nextButton, "the drawing library")
@@ -50,6 +57,11 @@ namespace OpenNest.Forms
Settled = UpdateAddCommand,
};
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
EditDrawing = edit =>
{
using var form = new EditLibraryDrawingForm(edit);
return form.ShowDialog(this) == DialogResult.OK ? form.Saved : null;
};
GridStyle.Apply(memberGrid);
GridStyle.Apply(libraryGrid);
@@ -185,6 +197,101 @@ namespace OpenNest.Forms
UpdateCommands();
}
// The quantity cell edits in place; a double-click anywhere else edits the drawing.
private async void MemberGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex >= 0 && e.ColumnIndex != quantityColumn.Index)
await EditSelectedDrawingAsync();
}
// A right-click selects the row under the pointer, so the menu acts on what was clicked.
private void MemberGrid_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e)
{
if (e.Button == MouseButtons.Right && e.RowIndex >= 0 && SelectedIndex() != e.RowIndex && memberGrid.EndEdit())
memberGrid.CurrentCell = memberGrid.Rows[e.RowIndex].Cells[drawingColumn.Index];
}
private void MemberMenu_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
var index = SelectedIndex();
editDrawingMenuItem.Enabled = !saving && !editing && index >= 0;
removeMenuItem.Enabled = !saving && !editing && index >= 0;
e.Cancel = index < 0;
}
private async void EditDrawingButton_Click(object sender, EventArgs e) => await EditSelectedDrawingAsync();
/// <summary>
/// Reads the selected member's current library record and opens Edit Drawing on it. The
/// member then shows what was read or saved; the set itself lists drawings by id, so it
/// is not changed and the next release takes the new revision.
/// </summary>
internal async Task EditSelectedDrawingAsync()
{
var index = SelectedIndex();
if (index < 0 || saving || editing || !memberGrid.EndEdit())
return;
var member = session.Members[index];
var name = member.Drawing?.Name ?? member.DrawingId.ToString();
DrawingLibraryRecord current;
editing = true;
UpdateCommands();
statusLabel.Text = $"Reading '{name}'...";
try
{
current = await library.GetAsync(member.DrawingId);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
if (!IsDisposed)
{
statusLabel.Text = "";
ShowMessage($"Could not read '{name}': {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
return;
}
finally
{
editing = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return;
statusLabel.Text = "";
if (current == null)
{
ShowMessage($"'{name}' is no longer in the library.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
ShowMemberDrawing(current);
if (current.Archived)
{
ShowMessage($"'{current.Name}' is archived in the library. Restore it there before editing it.",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var saved = EditDrawing(new DrawingLibraryEditSession(library, current, Environment.UserName));
if (saved == null || IsDisposed)
return;
ShowMemberDrawing(saved);
// The library pane may list the old details.
await RunLibraryAsync(librarySession.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved '{saved.Name}' revision {saved.Revision} to the library.";
}
private void ShowMemberDrawing(DrawingLibraryRecord drawing)
{
var index = SelectedIndex();
if (session.UpdateDrawing(drawing))
Populate(index);
}
private void UpButton_Click(object sender, EventArgs e) => MoveSelected(-1);
private void DownButton_Click(object sender, EventArgs e) => MoveSelected(1);
@@ -356,7 +463,7 @@ namespace OpenNest.Forms
if (drawing.Archived)
return "Archived in the library: skipped on release unless included";
if (string.IsNullOrWhiteSpace(drawing.MaterialName) || !(drawing.Thickness > 0))
return "No material or thickness: cannot be released until set in the library";
return "No material or thickness: double-click to set them before releasing";
return "";
}
@@ -367,6 +474,7 @@ namespace OpenNest.Forms
var index = SelectedIndex();
var error = session.GetError();
UpdateAddCommand();
editDrawingButton.Enabled = !saving && !editing && index >= 0;
removeButton.Enabled = !saving && index >= 0;
upButton.Enabled = !saving && index > 0;
downButton.Enabled = !saving && index >= 0 && index < session.Members.Count - 1;
+63 -3
View File
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
@@ -15,10 +17,14 @@ namespace OpenNest.Forms
/// material, grade, thickness, cut type; units are shown, not edited), notes and nesting
/// settings (Cut first, priority, rotation constraints, color) through
/// <see cref="DrawingLibraryEditSession"/>. Saving adds a revision on the server that
/// keeps the same package; nests that already hold the drawing keep their revision.
/// keeps the same package; nests that already hold the drawing keep their revision. When
/// the new details would duplicate another active library drawing (D4), the operator is
/// warned before saving.
/// </summary>
public partial class EditLibraryDrawingForm : Form
{
private const int CollisionNames = 5;
private readonly DrawingLibraryEditSession session;
private readonly decimal initialStep;
private readonly decimal initialStart;
@@ -111,10 +117,20 @@ namespace OpenNest.Forms
saving = true;
UpdateCommands();
statusLabel.Text = "Saving...";
var details = ReadDetails();
try
{
Saved = await session.SaveAsync(ReadDetails(), notesBox.Text, ReadSettings());
if (session.ChangesIdentity(details) && !await ConfirmIdentityAsync(details))
{
if (!IsDisposed)
statusLabel.Text = "";
return;
}
if (IsDisposed)
return;
statusLabel.Text = "Saving...";
Saved = await session.SaveAsync(details, notesBox.Text, ReadSettings());
if (IsDisposed)
return;
DialogResult = DialogResult.OK;
@@ -139,6 +155,50 @@ namespace OpenNest.Forms
}
}
/// <summary>
/// Looks up the active library drawings the new details would duplicate (D4) and, when
/// there are any or the lookup fails, asks whether to save anyway.
/// </summary>
private async Task<bool> ConfirmIdentityAsync(DrawingLibraryDetails details)
{
statusLabel.Text = "Checking the library for drawings with these details...";
IReadOnlyList<DrawingLibraryRecord> collisions;
try
{
collisions = await session.FindCollisionsAsync(details);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
if (IsDisposed)
return false;
return ShowMessage($"Could not check the library for drawings with these details: {ex.Message}"
+ Environment.NewLine + Environment.NewLine + "Save anyway?",
MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) == DialogResult.OK;
}
if (IsDisposed)
return false;
if (collisions.Count == 0)
return true;
return ShowMessage(DescribeCollisions(collisions), MessageBoxButtons.OKCancel, MessageBoxIcon.Warning)
== DialogResult.OK;
}
internal static string DescribeCollisions(IReadOnlyList<DrawingLibraryRecord> collisions)
{
var lines = collisions.Take(CollisionNames).Select(r =>
$"- '{r.Name}'" + (string.IsNullOrWhiteSpace(r.Customer) ? "" : $" ({r.Customer})")
+ $", {$"{r.MaterialName} {r.MaterialGrade}".Trim()} "
+ $"{r.Thickness.ToString("0.####", CultureInfo.CurrentCulture)} {UnitsHelper.GetShortString(r.Units)} {r.CutType}"
+ $", revision {r.Revision.ToString(CultureInfo.CurrentCulture)}");
var more = collisions.Count > CollisionNames ? $"{Environment.NewLine}...and {collisions.Count - CollisionNames} more." : "";
var count = collisions.Count == 1 ? "Another library drawing already has" : $"{collisions.Count} other library drawings already have";
return $"{count} this name, customer, material, grade, thickness and cut type:"
+ Environment.NewLine + string.Join(Environment.NewLine, lines) + more
+ Environment.NewLine + Environment.NewLine
+ "Saving makes them hard to tell apart, and Save to Drawing Library will offer to revise either. Save anyway?";
}
private bool CanSave()
{
var details = ReadDetails();
+10 -1
View File
@@ -38,6 +38,7 @@ namespace OpenNest.Forms
mnuFileNew = new System.Windows.Forms.ToolStripMenuItem();
mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem();
mnuFileImportBom = new System.Windows.Forms.ToolStripMenuItem();
mnuFileDrawingLibrary = new System.Windows.Forms.ToolStripMenuItem();
mnuFileDrawingSets = new System.Windows.Forms.ToolStripMenuItem();
mnuFileReleases = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator();
@@ -207,7 +208,7 @@ namespace OpenNest.Forms
//
// mnuFile
//
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit });
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingLibrary, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit });
mnuFile.Name = "mnuFile";
mnuFile.DropDownOpening += FileMenu_Opening;
mnuFile.Size = new System.Drawing.Size(37, 20);
@@ -238,6 +239,13 @@ namespace OpenNest.Forms
mnuFileImportBom.Text = "Import BOM...";
mnuFileImportBom.Click += ImportBom_Click;
//
// mnuFileDrawingLibrary
//
mnuFileDrawingLibrary.Name = "mnuFileDrawingLibrary";
mnuFileDrawingLibrary.Size = new System.Drawing.Size(180, 22);
mnuFileDrawingLibrary.Text = "Drawing Library...";
mnuFileDrawingLibrary.Click += FileDrawingLibrary_Click;
//
// mnuFileDrawingSets
//
mnuFileDrawingSets.Name = "mnuFileDrawingSets";
@@ -1356,6 +1364,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.ToolStripMenuItem mnuFileNew;
private System.Windows.Forms.ToolStripMenuItem mnuFileOpen;
private System.Windows.Forms.ToolStripMenuItem mnuFileImportBom;
private System.Windows.Forms.ToolStripMenuItem mnuFileDrawingLibrary;
private System.Windows.Forms.ToolStripMenuItem mnuFileDrawingSets;
private System.Windows.Forms.ToolStripMenuItem mnuFileReleases;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem1;
+32 -1
View File
@@ -98,15 +98,46 @@ namespace OpenNest.Forms
private void FileMenu_Opening(object sender, EventArgs e)
{
// Drawing sets live on the nest server; File mode has nothing to browse.
// The drawing library and sets live on the nest server; File mode has nothing to browse.
var databaseMode = NestStorage.Settings.IsDatabaseMode;
mnuFile.DropDown.ShowItemToolTips = true;
mnuFileDrawingLibrary.Enabled = databaseMode;
mnuFileDrawingLibrary.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
mnuFileDrawingSets.Enabled = databaseMode;
mnuFileDrawingSets.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
mnuFileReleases.Enabled = databaseMode;
mnuFileReleases.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
}
/// <summary>
/// Browses the drawing library and edits its drawings' details and settings; needs no
/// open nest. Drawings are added to sets or released from it, never to a nest.
/// </summary>
private void FileDrawingLibrary_Click(object sender, EventArgs e)
{
if (!NestStorage.Settings.IsDatabaseMode)
return;
IDrawingLibraryStore library;
IDrawingSetStore sets;
INestRepository nests;
try
{
library = NestStorage.CreateDrawingLibrary();
sets = NestStorage.CreateDrawingSetStore();
nests = NestStorage.CreateRepository();
}
catch (ArgumentException ex)
{
MessageBox.Show(this, ex.Message, "Drawing Library", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
using var form = new DrawingLibraryForm(library, NestStorage.Settings.ServerUrl, null, sets);
form.EnableRelease(nests, OpenServerNest, IsServerNestOpen);
form.ShowDialog(this);
}
/// <summary>
/// Browses, edits and releases drawing sets; needs no open nest, since a release
/// creates or merges into nests and opens each in its own tab.
+31 -5
View File
@@ -59,6 +59,8 @@ namespace OpenNest.Forms
closeButton = new System.Windows.Forms.Button();
statusLabel = new System.Windows.Forms.Label();
searchTimer = new System.Windows.Forms.Timer(components);
fixDrawingsButton = new System.Windows.Forms.Button();
groupMenu = new System.Windows.Forms.ContextMenuStrip(components);
layoutTable.SuspendLayout();
addSetTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)setGrid).BeginInit();
@@ -295,6 +297,7 @@ namespace OpenNest.Forms
//
groupGrid.AllowUserToAddRows = false;
groupGrid.AllowUserToDeleteRows = false;
groupGrid.ContextMenuStrip = groupMenu;
groupGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { includeColumn, materialColumn, gradeColumn, thicknessColumn, unitsColumn, cutTypeColumn, drawingsColumn, partsColumn, targetColumn, statusColumn });
groupGrid.Dock = System.Windows.Forms.DockStyle.Fill;
groupGrid.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnEnter;
@@ -307,6 +310,9 @@ namespace OpenNest.Forms
groupGrid.CellValueChanged += GroupGrid_CellValueChanged;
groupGrid.CurrentCellDirtyStateChanged += GroupGrid_CurrentCellDirtyStateChanged;
groupGrid.DataError += GroupGrid_DataError;
groupGrid.CellDoubleClick += GroupGrid_CellDoubleClick;
groupGrid.CellMouseDown += GroupGrid_CellMouseDown;
groupGrid.CurrentCellChanged += GroupGrid_CurrentCellChanged;
//
// includeColumn
//
@@ -405,13 +411,15 @@ namespace OpenNest.Forms
// bottomTable
//
bottomTable.AutoSize = true;
bottomTable.ColumnCount = 3;
bottomTable.ColumnCount = 4;
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.Controls.Add(summaryLabel, 0, 0);
bottomTable.Controls.Add(releaseButton, 1, 0);
bottomTable.Controls.Add(closeButton, 2, 0);
bottomTable.Controls.Add(fixDrawingsButton, 1, 0);
bottomTable.Controls.Add(releaseButton, 2, 0);
bottomTable.Controls.Add(closeButton, 3, 0);
bottomTable.Dock = System.Windows.Forms.DockStyle.Fill;
bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
bottomTable.Name = "bottomTable";
@@ -426,12 +434,23 @@ namespace OpenNest.Forms
summaryLabel.Name = "summaryLabel";
summaryLabel.TabIndex = 0;
//
// fixDrawingsButton
//
fixDrawingsButton.AutoSize = true;
fixDrawingsButton.Enabled = false;
fixDrawingsButton.Margin = new System.Windows.Forms.Padding(3, 3, 12, 3);
fixDrawingsButton.Name = "fixDrawingsButton";
fixDrawingsButton.TabIndex = 1;
fixDrawingsButton.Text = "Fix Drawings...";
fixDrawingsButton.UseVisualStyleBackColor = true;
fixDrawingsButton.Click += FixDrawingsButton_Click;
//
// releaseButton
//
releaseButton.AutoSize = true;
releaseButton.Enabled = false;
releaseButton.Name = "releaseButton";
releaseButton.TabIndex = 1;
releaseButton.TabIndex = 2;
releaseButton.Text = "Release";
releaseButton.UseVisualStyleBackColor = true;
releaseButton.Click += ReleaseButton_Click;
@@ -440,7 +459,7 @@ namespace OpenNest.Forms
//
closeButton.AutoSize = true;
closeButton.Name = "closeButton";
closeButton.TabIndex = 2;
closeButton.TabIndex = 3;
closeButton.Text = "Close";
closeButton.UseVisualStyleBackColor = true;
closeButton.Click += CloseButton_Click;
@@ -459,6 +478,11 @@ namespace OpenNest.Forms
searchTimer.Interval = 250;
searchTimer.Tick += SearchTimer_Tick;
//
// groupMenu
//
groupMenu.Name = "groupMenu";
groupMenu.Opening += GroupMenu_Opening;
//
// ReleaseSetForm
//
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
@@ -519,6 +543,8 @@ namespace OpenNest.Forms
private System.Windows.Forms.DataGridViewComboBoxColumn targetColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn statusColumn;
private System.Windows.Forms.Label skippedLabel;
private System.Windows.Forms.Button fixDrawingsButton;
private System.Windows.Forms.ContextMenuStrip groupMenu;
private System.Windows.Forms.TableLayoutPanel bottomTable;
private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.Button releaseButton;
+176 -1
View File
@@ -24,7 +24,10 @@ namespace OpenNest.Forms
/// the same material, whose layout is discarded. <see cref="SetReleaseSession"/> then builds
/// those nests, nests each one with Auto Nest when Nest after release is ticked, saves them,
/// opens each in its own tab, and records the release once every chosen nest exists. The
/// dialog shows every group's outcome; a release runs once per dialog.
/// dialog shows every group's outcome; a release runs once per dialog. A group refused for
/// its drawings (no material or thickness, or two with one name) is not a dead end: Fix
/// Drawings, a double-click or the group's right-click menu opens Edit Drawing on the
/// current library record, and the plan is rebuilt with what was saved.
/// </summary>
public partial class ReleaseSetForm : Form
{
@@ -32,6 +35,8 @@ namespace OpenNest.Forms
private readonly IDrawingSetStore sets;
private readonly IDrawingLibrary library;
// Edit Drawing needs a writable library; null hides it.
private readonly IDrawingLibraryStore libraryStore;
private readonly INestRepository nests;
private readonly string serverUrl;
private readonly Action<Nest, NestRecord> openNest;
@@ -47,6 +52,7 @@ namespace OpenNest.Forms
private bool populating;
private bool running;
private bool loadingDrafts;
private bool editing;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
@@ -72,6 +78,9 @@ namespace OpenNest.Forms
/// </summary>
internal Action ShowAddDrawings { get; set; }
/// <summary>Shows Edit Drawing and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingLibraryEditSession, DrawingLibraryRecord> EditDrawing { get; set; }
/// <summary>The finished release, once one has run; otherwise null.</summary>
public SetReleaseResult Result { get; private set; }
@@ -98,6 +107,7 @@ namespace OpenNest.Forms
InitializeComponent();
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
this.library = library ?? throw new ArgumentNullException(nameof(library));
libraryStore = library as IDrawingLibraryStore;
this.nests = nests ?? throw new ArgumentNullException(nameof(nests));
this.serverUrl = serverUrl ?? "";
this.openNest = openNest ?? throw new ArgumentNullException(nameof(openNest));
@@ -111,6 +121,12 @@ namespace OpenNest.Forms
id => DrawingLines().Any(l => l.Drawing.Id == id));
form.ShowDialog(this);
};
EditDrawing = edit =>
{
using var form = new EditLibraryDrawingForm(edit);
return form.ShowDialog(this) == DialogResult.OK ? form.Saved : null;
};
fixDrawingsButton.Visible = libraryStore != null;
GridStyle.Apply(setGrid);
GridStyle.Apply(groupGrid);
@@ -462,6 +478,163 @@ namespace OpenNest.Forms
populating = false;
}
private void GroupGrid_CurrentCellChanged(object sender, EventArgs e) => UpdateCommands();
// A right-click selects the row under the pointer, so the menu acts on what was clicked.
private void GroupGrid_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e)
{
if (e.Button == MouseButtons.Right && e.RowIndex >= 0 && groupGrid.CurrentRow?.Index != e.RowIndex)
groupGrid.CurrentCell = groupGrid.Rows[e.RowIndex].Cells[materialColumn.Index];
}
private async void GroupGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex >= 0 && groupGrid.Rows[e.RowIndex].Tag is SetReleaseGroup { CanRelease: false })
await FixSelectedGroupAsync();
}
private async void FixDrawingsButton_Click(object sender, EventArgs e) => await FixSelectedGroupAsync();
// One Edit Drawing item per drawing of the group, the ones it is refused for first.
private void GroupMenu_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
groupMenu.Items.Clear();
if (libraryStore == null || groupGrid.CurrentRow?.Tag is not SetReleaseGroup group)
{
e.Cancel = true;
return;
}
const int listed = 15;
var items = group.RefusedItems.Concat(group.Items.Where(i => !group.RefusedItems.Contains(i))).ToList();
var enabled = CanEditDrawings();
foreach (var item in items.Take(listed))
{
var menuItem = new ToolStripMenuItem($"Edit Drawing '{item.Drawing.Name}'...") { Enabled = enabled };
menuItem.Click += async (_, _) => await EditItemDrawingAsync(item);
groupMenu.Items.Add(menuItem);
}
if (items.Count > listed)
{
groupMenu.Items.Add(new ToolStripMenuItem(
$"...and {items.Count - listed} more: edit them in the set or the Drawing Library")
{ Enabled = false });
}
}
private bool CanEditDrawings() => libraryStore != null && !running && !editing && Result == null;
/// <summary>
/// Opens Edit Drawing, one after another, on each drawing the selected group is refused
/// for; cancelling one stops. The plan is rebuilt after each save.
/// </summary>
internal async Task FixSelectedGroupAsync()
{
if (!CanEditDrawings() || groupGrid.CurrentRow?.Tag is not SetReleaseGroup { CanRelease: false } group)
return;
foreach (var item in group.RefusedItems.ToList())
{
if (!await EditItemDrawingAsync(item) || IsDisposed)
return;
}
}
/// <summary>
/// Reads the item's current library record and opens Edit Drawing on it; true when it
/// was saved. What was read or saved replaces the drawing in the plan.
/// </summary>
internal async Task<bool> EditItemDrawingAsync(SetReleaseItem item)
{
ArgumentNullException.ThrowIfNull(item);
if (!CanEditDrawings())
return false;
var name = item.Drawing.Name;
DrawingLibraryRecord current;
editing = true;
UpdateCommands();
statusLabel.Text = $"Reading '{name}'...";
try
{
current = await libraryStore.GetAsync(item.Drawing.Id);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
if (!IsDisposed)
{
statusLabel.Text = "";
ShowMessage($"Could not read '{name}': {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
return false;
}
finally
{
editing = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return false;
statusLabel.Text = "";
if (current == null)
{
ShowMessage($"'{name}' is no longer in the library.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return false;
}
if (current.Archived)
{
ShowMessage($"'{current.Name}' is archived in the library. Restore it there before editing it.",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return false;
}
var saved = EditDrawing(new DrawingLibraryEditSession(libraryStore, current, Environment.UserName));
if (IsDisposed)
return false;
ApplyDrawing(saved ?? current);
if (saved == null)
return false;
statusLabel.Text = $"Saved '{saved.Name}' revision {saved.Revision} to the library.";
return true;
}
/// <summary>
/// Shows a newer library record of a listed drawing, in the sets that list it (their
/// joined members) and on its own line, and rebuilds the plan. Sets list drawings by id,
/// so the release takes the drawing as it now is.
/// </summary>
internal void ApplyDrawing(DrawingLibraryRecord drawing)
{
ArgumentNullException.ThrowIfNull(drawing);
var changed = false;
foreach (DataGridViewRow row in setGrid.Rows)
{
if (row.Tag is DrawingSetRecord set)
{
foreach (var member in set.Members ?? new List<DrawingSetMember>())
{
if (member.DrawingId == drawing.Id && (member.Drawing == null || member.Drawing.Revision < drawing.Revision))
{
member.Drawing = drawing;
changed = true;
}
}
}
else if (row.Tag is DrawingLibraryRecord listed && listed.Id == drawing.Id && listed.Revision < drawing.Revision)
{
row.Tag = drawing;
row.Cells[setNameColumn.Index].Value = drawing.Name;
row.Cells[setCustomerColumn.Index].Value = drawing.Customer;
row.Cells[setRevisionColumn.Index].Value = drawing.Revision;
changed = true;
}
}
if (changed)
BuildPlan();
}
private async void ReleaseButton_Click(object sender, EventArgs e)
{
if (Result is { Release: null, ReleaseError.Length: > 0 })
@@ -790,6 +963,8 @@ namespace OpenNest.Forms
}
}
fixDrawingsButton.Enabled = CanEditDrawings() && groupGrid.CurrentRow?.Tag is SetReleaseGroup { CanRelease: false };
var retry = Result is { Release: null, ReleaseError.Length: > 0 };
releaseButton.Text = retry ? "Record Release" : "Release";
releaseButton.Enabled = !running && (retry || (editable && chosen.Count > 0));
+49 -13
View File
@@ -321,17 +321,50 @@ is edited in place (0 to 99,999; one click starts editing, a bad number keeps
the editor open with an error) and applies to the nest at once, like an add;
the main window's drawing list refreshes when the dialog closes.
The browser's **Cut first** column shows the setting. **Edit Drawing...**
(`OpenNest.Data.DrawingLibraryEditSession`) edits the selected active drawing's
details (name, customer, material, grade, thickness and cut type; text is
trimmed, the name is required, units are shown but not editable since they give
the geometry its meaning) and its Cut first flag, priority, rotation
step/start/end (degrees), 180° equivalent, color and notes, and saves them
through the metadata endpoint guarded by the listed revision: a new revision
with the same package. A material or thickness change is an edit of the same
drawing, not a new one. A 409 is shown and the operator reloads. Nests that
already hold the drawing keep their copy and revision; sets list the drawing,
so their next release uses the new details.
The browser's **Cut first** column shows the setting.
**File > Drawing Library...** (Database mode only, like Drawing Sets) opens the
same browser without a nest: it lists every active drawing, has no Add to Nest
or In This Nest tab, and a double-click or Enter edits the selected drawing. Its
right-click menu has Edit Drawing and the set and release items below.
### Edit Drawing (desktop)
**Edit Drawing...** (`OpenNest.Data.DrawingLibraryEditSession`) edits one active
library drawing. Its **Details** are the name, customer, material, grade,
thickness and cut type; **Units** are shown read-only, since changing them would
change what the geometry means. Its **Nesting settings** are Cut first, priority,
rotation step/start/end (degrees), 180° equivalent and color, then the notes.
Text is trimmed and thickness rounded as the server stores it; Save stays
disabled until something changed and every value passes the library rules.
When the new details would give the drawing another identity (D4: normalized
customer, name, material and grade, exact thickness, units and cut type), Save
first looks up active library drawings that already have them and, if any do,
names them and asks before saving; a failed lookup also asks. Editing only notes
or settings never asks.
Save goes through `PUT /api/drawings/{id}/metadata`, guarded by the revision
the dialog opened with: a new revision with the same package. A material or
thickness change is an edit of the same drawing, not a new one. A 409 (someone
else saved first) is shown, nothing is saved and the dialog stays open; close it
and edit again. Nests that already hold the drawing keep their copy and
revision. Sets list drawings by id, so they release the new revision next time.
Edit Drawing opens from:
- the Drawing Library dialog (the **Edit Drawing...** button or the drawing's
right-click menu), which reloads the page after a save;
- the set editor's member grid: double-click a member (outside its quantity),
right-click it, or **Edit Drawing...** beside the grid. It first reads the
member's current record, so a set read earlier does not cause a conflict; the
row then shows what was saved. The set itself is unchanged and needs no save;
- the release dialog: a group refused for its drawings (no material or
thickness, or two drawings with one name) has **Fix Drawings...**, which opens
Edit Drawing on each drawing it is refused for in turn (Cancel stops), as does
a double-click on the group. Its right-click menu lists Edit Drawing for each of
the group's drawings. Each edit reads the current record first, and the plan
is rebuilt with what was saved, so a fixed drawing joins its material group.
### Save to Drawing Library (desktop)
@@ -387,7 +420,8 @@ a set shows its notes.
in the grid (1 to 100,000); **Remove**, **Move Up** and **Move Down** change
the list. The note column flags drawings archived in the library (skipped on
release unless included) and drawings with no material or thickness (cannot be
released until set in the library).
released until set; double-click to open
[Edit Drawing](#edit-drawing-desktop)).
- **Save** adds a revision guarded by the revision the set was read at; a 409 is
shown and the operator cancels and reloads. A new set's id is allocated
before its first save, and a save that fails without a definite answer stays
@@ -441,7 +475,9 @@ drawings, parts, **Nest** and status. **Include archived drawings** puts archive
members back in, otherwise they are named below the grid as left out. Each group
has a **Release** tick; refused groups (two drawings with one name, or drawings
without material or thickness) show why and cannot be ticked, and a tick
survives changing the sets, kits or the archived toggle.
survives changing the sets, kits or the archived toggle. **Fix Drawings...**
opens [Edit Drawing](#edit-drawing-desktop) on a refused group's drawings and
rebuilds the plan with what was saved.
**Nest** is **New Draft nest** or **Merge into** one of the Draft nests of the
group's material, grade and thickness, listed with their sheet count and when