From 0d1dade063f1d6d93ddc430d1b4af8674c965905 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 10:51:58 -0400 Subject: [PATCH] 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 --- OpenNest.Data/DrawingLibraryBrowseSession.cs | 10 +- OpenNest.Data/DrawingLibraryEditSession.cs | 26 +++ OpenNest.Data/DrawingLibrarySaveSession.cs | 82 ++++---- OpenNest.Data/DrawingSetEditSession.cs | 15 ++ OpenNest.Data/SetReleasePlan.cs | 21 ++- .../DrawingLibraryEndToEndTests.cs | 44 +++++ .../Data/DrawingLibraryBrowseSessionTests.cs | 13 ++ .../Data/DrawingLibraryEditSessionTests.cs | 50 ++++- .../Data/DrawingSetEditSessionTests.cs | 17 ++ OpenNest.Tests/Data/SetReleasePlanTests.cs | 3 + .../Forms/DrawingLibraryFormTests.cs | 92 +++++++++ .../Forms/DrawingSetFormsTests.cs | 88 ++++++++- .../Forms/SetReleaseFormsTests.cs | 89 ++++++++- OpenNest/Forms/DrawingLibraryForm.Designer.cs | 16 +- OpenNest/Forms/DrawingLibraryForm.cs | 61 ++++-- OpenNest/Forms/EditDrawingSetForm.Designer.cs | 47 ++++- OpenNest/Forms/EditDrawingSetForm.cs | 118 +++++++++++- OpenNest/Forms/EditLibraryDrawingForm.cs | 66 ++++++- OpenNest/Forms/MainForm.Designer.cs | 11 +- OpenNest/Forms/MainForm.File.cs | 33 +++- OpenNest/Forms/ReleaseSetForm.Designer.cs | 36 +++- OpenNest/Forms/ReleaseSetForm.cs | 177 +++++++++++++++++- docs/nest-storage.md | 62 ++++-- 23 files changed, 1073 insertions(+), 104 deletions(-) diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs index 077a9904..04a6e786 100644 --- a/OpenNest.Data/DrawingLibraryBrowseSession.cs +++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs @@ -11,17 +11,17 @@ public sealed class DrawingLibraryBrowseSession : BrowseSessionThe nest to filter to, or null to list every active drawing (File > Drawing Library). + 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; } diff --git a/OpenNest.Data/DrawingLibraryEditSession.cs b/OpenNest.Data/DrawingLibraryEditSession.cs index 990a066f..78532034 100644 --- a/OpenNest.Data/DrawingLibraryEditSession.cs +++ b/OpenNest.Data/DrawingLibraryEditSession.cs @@ -77,6 +77,32 @@ public sealed class DrawingLibraryEditSession SameDetails(Build(details, notes, settings), Listed) && (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings); + /// + /// 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). + /// + public bool ChangesIdentity(DrawingLibraryDetails details) => + !DrawingLibraryDuplicates.SameDuplicateKey(BuildDetails(details), Listed); + + /// + /// 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. + /// + public async Task> FindCollisionsAsync(DrawingLibraryDetails details, + CancellationToken cancellationToken = default) + { + var record = BuildDetails(details); + if (!ChangesIdentity(details) || DrawingLibraryRules.GetMetadataError(record) != null) + return Array.Empty(); + 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()); + /// Saves the edit as the next revision; returns the stored record. public async Task SaveAsync(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings, CancellationToken cancellationToken = default) diff --git a/OpenNest.Data/DrawingLibrarySaveSession.cs b/OpenNest.Data/DrawingLibrarySaveSession.cs index a2789a32..227c2643 100644 --- a/OpenNest.Data/DrawingLibrarySaveSession.cs +++ b/OpenNest.Data/DrawingLibrarySaveSession.cs @@ -85,6 +85,51 @@ public sealed class DrawingLibraryDuplicates /// True when these duplicates were found for exactly 's matching details. public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata); + /// + /// The active library drawings whose duplicate key equals 's, + /// newest first, reading every page the server filters down to; none when it is invalid. + /// + internal static async Task> FindMatchesAsync(IDrawingLibrary library, + DrawingLibraryRecord metadata, CancellationToken cancellationToken) + { + var matches = new List(); + 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(); - 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, diff --git a/OpenNest.Data/DrawingSetEditSession.cs b/OpenNest.Data/DrawingSetEditSession.cs index 773d69e9..620e1b07 100644 --- a/OpenNest.Data/DrawingSetEditSession.cs +++ b/OpenNest.Data/DrawingSetEditSession.cs @@ -131,6 +131,21 @@ public sealed class DrawingSetEditSession _members[index].Quantity = quantity; } + /// + /// Shows , 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. + /// + 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; + } + /// The record a save sends. public DrawingSetRecord Build() => new() { diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs index 708b99e4..4cd1abc5 100644 --- a/OpenNest.Data/SetReleasePlan.cs +++ b/OpenNest.Data/SetReleasePlan.cs @@ -64,7 +64,8 @@ public sealed class SetReleaseItem public sealed class SetReleaseGroup { internal SetReleaseGroup(string key, DrawingLibraryRecord first, bool unassigned, - IReadOnlyList items, IReadOnlyList warnings, string refusal) + IReadOnlyList items, IReadOnlyList warnings, string refusal, + IReadOnlyList refusedItems) { Key = key; MaterialName = (first.MaterialName ?? "").Trim(); @@ -76,6 +77,7 @@ public sealed class SetReleaseGroup Items = items; Warnings = warnings; Refusal = refusal; + RefusedItems = refusedItems; } /// Identifies the group across rebuilds of the plan (for example after the multiplier changes). @@ -112,6 +114,12 @@ public sealed class SetReleaseGroup public string Refusal { get; } public bool CanRelease => Refusal.Length == 0; + + /// + /// 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. + /// + public IReadOnlyList RefusedItems { get; } } /// @@ -312,21 +320,26 @@ public sealed class SetReleasePlan .ToList(); var refusal = ""; + IReadOnlyList refused = Array.Empty(); 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 items) diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs index 66e8de16..82275535 100644 --- a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs @@ -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(() => 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() { diff --git a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs index 825a94bb..dcc80792 100644 --- a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs @@ -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() { diff --git a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs index 20ca6b79..2dbcb1fa 100644 --- a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs @@ -117,6 +117,44 @@ public class DrawingLibraryEditSessionTests Assert.Throws(() => 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 QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public List Library { get; } = new(); + + public List Queries { get; } = new(); + + // Every record, as a server that ignored the filters would; the session checks the key itself. + public Task 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 GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/OpenNest.Tests/Data/DrawingSetEditSessionTests.cs b/OpenNest.Tests/Data/DrawingSetEditSessionTests.cs index 4596d065..d792b689 100644 --- a/OpenNest.Tests/Data/DrawingSetEditSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingSetEditSessionTests.cs @@ -31,6 +31,23 @@ public class DrawingSetEditSessionTests Assert.Throws(() => 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() { diff --git a/OpenNest.Tests/Data/SetReleasePlanTests.cs b/OpenNest.Tests/Data/SetReleasePlanTests.cs index a4958f8e..7ec6220d 100644 --- a/OpenNest.Tests/Data/SetReleasePlanTests.cs +++ b/OpenNest.Tests/Data/SetReleasePlanTests.cs @@ -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); } diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs index f6e13d6b..f12290dd 100644 --- a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs @@ -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(); + 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(editor, "nameBox").Text = "SYN-707"; + Field(editor, "materialBox").Text = "Synthetic Steel"; + Field(editor, "gradeBox").Text = "Grade A"; + Field(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(editor, "nameBox").Text = "SYN-606"; + Field(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(); + + using var form = new DrawingLibraryForm(library, Server, null); + form.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + form.LoadPreview = (_, _) => Task.FromResult(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(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(form, "nestTab"), Field(form, "tabControl").TabPages.Cast()); + Assert.False(Field public partial class EditDrawingSetForm : Form { private readonly DrawingSetEditSession session; + private readonly IDrawingLibraryStore library; private readonly DrawingLibraryBrowseSession librarySession; private readonly BrowsePager libraryPager; private bool loading; private bool saving; + private bool editing; /// Shows a message and returns the operator's answer; replaced by tests. internal Func ShowMessage { get; set; } + /// Shows Edit Drawing and returns the saved record, or null; replaced by tests. + internal Func EditDrawing { get; set; } + /// The saved revision once the dialog closes with OK; otherwise null. 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( 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(); + + /// + /// 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. + /// + 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; diff --git a/OpenNest/Forms/EditLibraryDrawingForm.cs b/OpenNest/Forms/EditLibraryDrawingForm.cs index ae122f8b..7c1bebb1 100644 --- a/OpenNest/Forms/EditLibraryDrawingForm.cs +++ b/OpenNest/Forms/EditLibraryDrawingForm.cs @@ -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 /// . 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. /// 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 } } + /// + /// 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. + /// + private async Task ConfirmIdentityAsync(DrawingLibraryDetails details) + { + statusLabel.Text = "Checking the library for drawings with these details..."; + IReadOnlyList 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 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(); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index cb245b4f..6188dad6 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -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; diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs index 59801fd7..a9633040 100644 --- a/OpenNest/Forms/MainForm.File.cs +++ b/OpenNest/Forms/MainForm.File.cs @@ -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; } + /// + /// 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. + /// + 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); + } + /// /// 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. diff --git a/OpenNest/Forms/ReleaseSetForm.Designer.cs b/OpenNest/Forms/ReleaseSetForm.Designer.cs index f5716fc9..da6937fe 100644 --- a/OpenNest/Forms/ReleaseSetForm.Designer.cs +++ b/OpenNest/Forms/ReleaseSetForm.Designer.cs @@ -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; diff --git a/OpenNest/Forms/ReleaseSetForm.cs b/OpenNest/Forms/ReleaseSetForm.cs index 26624472..97ee4774 100644 --- a/OpenNest/Forms/ReleaseSetForm.cs +++ b/OpenNest/Forms/ReleaseSetForm.cs @@ -24,7 +24,10 @@ namespace OpenNest.Forms /// the same material, whose layout is discarded. 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. /// 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 openNest; @@ -47,6 +52,7 @@ namespace OpenNest.Forms private bool populating; private bool running; private bool loadingDrafts; + private bool editing; /// Shows a message and returns the operator's answer; replaced by tests. internal Func ShowMessage { get; set; } @@ -72,6 +78,9 @@ namespace OpenNest.Forms /// internal Action ShowAddDrawings { get; set; } + /// Shows Edit Drawing and returns the saved record, or null; replaced by tests. + internal Func EditDrawing { get; set; } + /// The finished release, once one has run; otherwise null. 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; + + /// + /// 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. + /// + 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; + } + } + + /// + /// 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. + /// + internal async Task 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; + } + + /// + /// 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. + /// + 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()) + { + 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)); diff --git a/docs/nest-storage.md b/docs/nest-storage.md index c29e7d52..7d9b8556 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -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