feat(desktop): release drawing sets into draft nests and track them

A set is only useful on the floor once it becomes nests. Release on the
Sets tab previews one row per material group with a kit multiplier, an
archived-drawing toggle and a tick per group, then builds each ticked
group's nest as File > New would, in the group's units, uploads it as a
Draft, opens it in its own tab bound to the server record and records the
release. Every group's outcome stays on screen, and a release whose
nests exist but whose record failed can be recorded again.

Releases shows each release's nests and how far they have got ("2 of 3
cut"), and Nest Info names the release a nest came from, so a kit run can
be followed across its nests. Plate defaults convert between inches and
millimeters so a group drawn in other units still gets sensible plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 15:12:11 -04:00
1 parent 1ccba9b910
commit cc731ff3d6
22 files changed
+1915 -21

No files matched your search

+10
View File
@@ -32,5 +32,15 @@ namespace OpenNest
NestStatus.ToBeCut,
NestStatus.HasBeenCut,
};
/// <summary>The name shown to the operator, such as "To Be Cut".</summary>
public static string DisplayName(NestStatus status) => status switch
{
NestStatus.Draft => "Draft",
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
_ => status.ToString(),
};
}
}
+22
View File
@@ -186,6 +186,28 @@ public sealed class NestDefaults
};
}
/// <summary>
/// These defaults in <paramref name="units"/>: plate size and spacings are converted
/// (25.4 mm per inch) and rounded to four decimals; the quadrant is kept. Returns a copy
/// even when the units already match.
/// </summary>
public NestDefaults ConvertedTo(Units units)
{
var factor = units == Units ? 1.0 : units == Units.Millimeters ? MillimetersPerInch : 1 / MillimetersPerInch;
double Convert(double value) => System.Math.Round(value * factor, 4, MidpointRounding.AwayFromZero);
return new NestDefaults
{
Units = units,
Size = new Size(Convert(Size.Width), Convert(Size.Length)),
Quadrant = Quadrant,
PartSpacing = Convert(PartSpacing),
EdgeSpacing = new Spacing(Convert(EdgeSpacing.Left), Convert(EdgeSpacing.Bottom),
Convert(EdgeSpacing.Right), Convert(EdgeSpacing.Top)),
};
}
private const double MillimetersPerInch = 25.4;
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
+4
View File
@@ -108,6 +108,10 @@ public sealed class SetReleaseResult
/// <see cref="SetReleaseSession.RetryReleaseAsync"/> posts the same release again.
/// </summary>
public string ReleaseError { get; }
/// <summary>This result once <see cref="SetReleaseSession.RetryReleaseAsync"/> has recorded <paramref name="release"/>.</summary>
public SetReleaseResult WithRelease(SetReleaseRecord release) =>
new(Groups, Canceled, release ?? throw new ArgumentNullException(nameof(release)), "");
}
/// <summary>
+62
View File
@@ -0,0 +1,62 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>Operator-facing wording for set releases, shared by the release views and Nest Info.</summary>
public static class SetReleaseText
{
/// <summary>Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith".</summary>
public static string Origin(SetReleaseRecord release)
{
ArgumentNullException.ThrowIfNull(release);
var text = $"Released from set '{release.SetName}' revision {release.SetRevision} ×{release.Multiplier}";
if (release.ReleasedAt != default)
text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}";
return string.IsNullOrWhiteSpace(release.ReleasedBy) ? text : $"{text} by {release.ReleasedBy}";
}
/// <summary>
/// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted".
/// Deleted nests are left out of the count.
/// </summary>
public static string Progress(SetReleaseRecord release)
{
ArgumentNullException.ThrowIfNull(release);
var nests = release.Nests ?? Array.Empty<SetReleaseNest>();
var deleted = nests.Count(IsDeleted);
var active = nests.Count - deleted;
if (active == 0)
return nests.Count == 1 ? "Its nest was deleted" : "All nests deleted";
var cut = nests.Count(n => !IsDeleted(n) && n.Nest?.Status == NestStatus.HasBeenCut);
var text = $"{cut} of {active} cut";
return deleted == 0 ? text : $"{text}, {deleted} deleted";
}
/// <summary>The state of one nest of a release: its status name, or "Deleted".</summary>
public static string NestState(SetReleaseNest nest)
{
ArgumentNullException.ThrowIfNull(nest);
if (IsDeleted(nest))
return "Deleted";
return nest.Nest is { } record ? NestStatuses.DisplayName(record.Status) : "";
}
/// <summary>The material group a release nest was made for, such as "Carbon Steel A36 0.25 in laser".</summary>
public static string Group(SetReleaseNest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var parts = new[]
{
nest.MaterialName,
nest.MaterialGrade,
$"{nest.Thickness.ToString("0.####", CultureInfo.CurrentCulture)} {UnitsHelper.GetShortString(nest.Units)}",
nest.CutType.ToString().ToLowerInvariant(),
};
return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()));
}
// A nest the server no longer joins is as gone as a deleted one.
private static bool IsDeleted(SetReleaseNest nest) => nest.Nest is null || nest.Nest.Deleted;
}
+45 -5
View File
@@ -111,14 +111,54 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
});
}
public Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
/// <summary>Stored releases, oldest first; tests may add ones with joined nest metadata.</summary>
public List<SetReleaseRecord> Releases { get; } = new();
public int PostAttempts { get; private set; }
public bool FailNextPost { get; set; }
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default)
{
PostAttempts++;
if (FailNextPost)
{
FailNextPost = false;
throw new IOException("Synthetic release post failure.");
}
if (Releases.FirstOrDefault(r => r.Id == release.Id) is { } existing)
throw new SetReleasePostConflictException(existing);
var stored = new SetReleaseRecord
{
Id = release.Id,
SetId = setId,
SetRevision = release.SetRevision,
SetName = _revisions[setId].First(r => r.Revision == release.SetRevision).Name,
Multiplier = release.Multiplier,
Notes = release.Notes,
ReleasedBy = release.ReleasedBy,
ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc),
Nests = release.Nests.ToList(),
};
Releases.Add(stored);
return Task.FromResult(stored);
}
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
{
if (!_revisions.ContainsKey(setId))
return Task.FromResult<SetReleasePage?>(null);
var newest = Enumerable.Reverse(Releases.Where(r => r.SetId == setId).ToList()).ToList();
return Task.FromResult<SetReleasePage?>(new SetReleasePage
{
Items = newest.Skip(offset).Take(limit).ToList(),
Total = newest.Count,
Offset = offset,
Limit = limit,
});
}
public Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
Task.FromResult(Releases.FirstOrDefault(r => r.Nests.Any(n => n.NestId == nestId)));
private Task<DrawingSetRecord> SetArchived(Guid id, int expectedRevision, bool archived)
{
@@ -0,0 +1,80 @@
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
public class SetReleaseTextTests
{
private static SetReleaseNest Nest(NestStatus status, bool deleted = false, bool joined = true) => new()
{
NestId = Guid.NewGuid(),
MaterialName = "Synthetic Steel",
MaterialGrade = "Grade A",
Thickness = 0.25,
Units = Units.Inches,
CutType = CutType.Waterjet,
Nest = joined ? new NestRecord { Name = "N1", Status = status, Deleted = deleted } : null,
};
[Fact]
public void Progress_CountsCutNestsAndLeavesDeletedOnesOut()
{
Assert.Equal("2 of 3 cut", SetReleaseText.Progress(new SetReleaseRecord
{
Nests = new[] { Nest(NestStatus.HasBeenCut), Nest(NestStatus.HasBeenCut), Nest(NestStatus.Draft) },
}));
Assert.Equal("0 of 1 cut, 2 deleted", SetReleaseText.Progress(new SetReleaseRecord
{
Nests = new[] { Nest(NestStatus.ToBeCut), Nest(NestStatus.HasBeenCut, deleted: true), Nest(NestStatus.Quote, joined: false) },
}));
Assert.Equal("All nests deleted", SetReleaseText.Progress(new SetReleaseRecord
{
Nests = new[] { Nest(NestStatus.Draft, deleted: true), Nest(NestStatus.Draft, deleted: true) },
}));
}
[Fact]
public void NestState_AndGroup_DescribeOneNest()
{
Assert.Equal("To Be Cut", SetReleaseText.NestState(Nest(NestStatus.ToBeCut)));
Assert.Equal("Deleted", SetReleaseText.NestState(Nest(NestStatus.ToBeCut, deleted: true)));
Assert.Equal("Synthetic Steel Grade A 0.25 in waterjet", SetReleaseText.Group(Nest(NestStatus.Draft)));
}
[Fact]
public void Origin_NamesTheSetRevisionMultiplierAndWho()
{
var text = SetReleaseText.Origin(new SetReleaseRecord
{
SetName = "Synthetic kit",
SetRevision = 3,
Multiplier = 2,
ReleasedBy = "Synthetic operator",
});
Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text);
}
[Fact]
public void NestDefaults_ConvertedTo_ScalesPlateDefaultsBetweenUnits()
{
var inches = new NestDefaults
{
Units = Units.Inches,
Size = new Size(60, 120),
Quadrant = 3,
PartSpacing = 0.125,
EdgeSpacing = new Spacing(0.5, 0.25, 0.5, 0.25),
};
var millimeters = inches.ConvertedTo(Units.Millimeters);
var back = millimeters.ConvertedTo(Units.Inches);
Assert.Equal((Units.Millimeters, 1524.0, 3048.0, 3, 3.175), (millimeters.Units, millimeters.Size.Width,
millimeters.Size.Length, millimeters.Quadrant, millimeters.PartSpacing));
Assert.Equal((12.7, 6.35, 12.7, 6.35), (millimeters.EdgeSpacing.Left, millimeters.EdgeSpacing.Bottom,
millimeters.EdgeSpacing.Right, millimeters.EdgeSpacing.Top));
Assert.Equal((60.0, 120.0, 0.125), (back.Size.Width, back.Size.Length, back.PartSpacing));
Assert.NotSame(inches, inches.ConvertedTo(Units.Inches));
}
}
@@ -0,0 +1,330 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Forms;
using OpenNest.IO;
using OpenNest.Shapes;
using OpenNest.Tests.Data;
using DrawingSetBrowser = OpenNest.Controls.DrawingSetBrowser;
namespace OpenNest.WinForms.Tests.Forms;
public class SetReleaseFormsTests
{
private const string Server = "http://library.test:8090";
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60);
[Fact]
public void Release_CreatesOneDraftNestPerTickedGroup_OpensEach_AndRecordsTheRelease()
{
StaTestThread.Run(
() =>
{
var library = new PackageLibrary();
var a = library.Add("SYN-A", "Synthetic Steel");
var b = library.Add("SYN-B", "Synthetic Steel");
var c = library.Add("SYN-C", "Synthetic Aluminum");
var old = library.Add("SYN-OLD", "Synthetic Steel", archived: true);
var sets = new InMemoryDrawingSets();
var set = CreateSet(sets, (a, 2), (b, 1), (c, 4), (old, 1));
var nests = new MemoryNests();
var opened = new List<(Nest Nest, NestRecord Record)>();
var questions = new List<string>();
using var form = new ReleaseSetForm(sets, library, nests, Server, set, (nest, record) => opened.Add((nest, record)));
form.ShowMessage = (text, _, _) => { questions.Add(text); return DialogResult.OK; };
var created = 0;
form.CreateNest = (group, _) => Task.FromResult(new Nest($"N26-{++created:D4}") { Units = group.Units });
form.Show();
var grid = Field<DataGridView>(form, "groupGrid");
var skipped = Field<Label>(form, "skippedLabel");
Assert.Equal(2, grid.Rows.Count);
Assert.True(skipped.Visible);
Assert.Contains("'SYN-OLD'", skipped.Text);
Field<NumericUpDown>(form, "multiplierBox").Value = 3;
Assert.Equal(new object[] { 12, 9 }, grid.Rows.Cast<DataGridViewRow>().Select(r => r.Cells["partsColumn"].Value));
Field<TextBox>(form, "notesBox").Text = "Synthetic run";
Run(form.ReleaseAsync());
Assert.Contains("Create 2 Draft nests from set 'Synthetic kit' ×3", Assert.Single(questions));
Assert.Equal(2, nests.Uploaded.Count);
Assert.Equal(2, opened.Count);
Assert.All(opened, o => Assert.Equal(NestStatus.Draft, o.Nest.Status));
var steel = opened.Single(o => o.Nest.Material.Name == "Synthetic Steel");
Assert.Equal(new[] { ("SYN-A", 6), ("SYN-B", 3) },
steel.Nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required)));
Assert.Equal(steel.Record.Id, nests.Uploaded.Single(u => u.Name == steel.Nest.Name).Id);
var release = Assert.Single(sets.Releases);
Assert.Equal((3, "Synthetic run", 2), (release.Multiplier, release.Notes, release.Nests.Count));
Assert.Same(release, form.Result!.Release);
Assert.StartsWith("Released: 2 nests", Field<Label>(form, "statusLabel").Text);
Assert.All(grid.Rows.Cast<DataGridViewRow>(), r => Assert.StartsWith("Created N26-", (string)r.Cells["statusColumn"].Value));
Assert.False(Field<Button>(form, "releaseButton").Enabled);
Assert.False(Field<NumericUpDown>(form, "multiplierBox").Enabled);
},
TestTimeout,
"The release test did not complete.");
}
[Fact]
public void UntickedAndRefusedGroups_AreNotReleased()
{
StaTestThread.Run(
() =>
{
var library = new PackageLibrary();
var a = library.Add("SYN-A", "Synthetic Steel");
var twin = library.Add("SYN-A", "Synthetic Steel");
var c = library.Add("SYN-C", "Synthetic Aluminum");
var d = library.Add("SYN-D", "Synthetic Copper");
var sets = new InMemoryDrawingSets();
var set = CreateSet(sets, (a, 1), (twin, 1), (c, 1), (d, 1));
var nests = new MemoryNests();
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.Show();
var grid = Field<DataGridView>(form, "groupGrid");
var rows = grid.Rows.Cast<DataGridViewRow>().ToList();
var refused = rows.Single(r => ((SetReleaseGroup)r.Tag!).MaterialName == "Synthetic Steel");
var copper = rows.Single(r => ((SetReleaseGroup)r.Tag!).MaterialName == "Synthetic Copper");
Assert.Equal(false, refused.Cells["includeColumn"].Value);
Assert.True(refused.Cells["includeColumn"].ReadOnly);
Assert.Contains("named 'SYN-A'", (string)refused.Cells["statusColumn"].Value);
copper.Cells["includeColumn"].Value = false;
Assert.Contains("1 of 3 groups chosen", Field<Label>(form, "summaryLabel").Text);
// Rebuilding the plan keeps the operator's choice.
Field<CheckBox>(form, "includeArchivedCheckBox").Checked = true;
Assert.Equal(false, grid.Rows.Cast<DataGridViewRow>()
.Single(r => ((SetReleaseGroup)r.Tag!).MaterialName == "Synthetic Copper").Cells["includeColumn"].Value);
Run(form.ReleaseAsync());
Assert.Equal("Synthetic Aluminum", Assert.Single(Assert.Single(sets.Releases).Nests).MaterialName);
Assert.Single(nests.Uploaded);
},
TestTimeout,
"The release choice test did not complete.");
}
[Fact]
public void ReleaseNotRecorded_CanBeRecordedAgain()
{
StaTestThread.Run(
() =>
{
var library = new PackageLibrary();
var a = library.Add("SYN-A", "Synthetic Steel");
var sets = new InMemoryDrawingSets();
var set = CreateSet(sets, (a, 1));
sets.FailNextPost = true;
var opened = 0;
using var form = new ReleaseSetForm(sets, library, new MemoryNests(), Server, set, (_, _) => opened++);
form.ShowMessage = (_, _, _) => DialogResult.OK;
form.CreateNest = (group, _) => Task.FromResult(new Nest("N") { Units = group.Units });
form.Show();
var release = Field<Button>(form, "releaseButton");
Run(form.ReleaseAsync());
Assert.Equal(1, opened);
Assert.Empty(sets.Releases);
Assert.Equal(("Record Release", true), (release.Text, release.Enabled));
Assert.Contains("Synthetic release post failure.", Field<Label>(form, "statusLabel").Text);
Run(form.RetryRecordAsync());
Assert.Equal(2, sets.PostAttempts);
Assert.Same(Assert.Single(sets.Releases), form.Result!.Release);
Assert.Equal(("Release", false), (release.Text, release.Enabled));
},
TestTimeout,
"The release retry test did not complete.");
}
[Fact]
public void Releases_ListsEachReleaseWithProgress_AndItsNestsStates()
{
StaTestThread.Run(
() =>
{
var sets = new InMemoryDrawingSets();
var set = CreateSet(sets);
SetReleaseNest Nest(string name, NestStatus status, bool deleted = false) => new()
{
NestId = Guid.NewGuid(),
MaterialName = "Synthetic Steel",
Thickness = 0.25,
Nest = new NestRecord { Name = name, Status = status, Deleted = deleted },
};
sets.Releases.Add(new SetReleaseRecord
{
Id = Guid.NewGuid(),
SetId = set.Id,
SetRevision = 1,
SetName = set.Name,
Multiplier = 2,
ReleasedBy = "Synthetic operator",
ReleasedAt = new DateTime(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc),
Nests = new[] { Nest("N1", NestStatus.HasBeenCut), Nest("N2", NestStatus.ToBeCut), Nest("N3", NestStatus.Draft, deleted: true) },
});
using var form = new SetReleasesForm(sets, set);
form.Show();
var releases = Field<DataGridView>(form, "releaseGrid");
var nests = Field<DataGridView>(form, "nestGrid");
PumpUntil(() => releases.Rows.Count == 1 && nests.Rows.Count == 3, "the releases");
Assert.Equal("1 of 2 cut, 1 deleted", releases.Rows[0].Cells["progressColumn"].Value);
Assert.Equal("×2", releases.Rows[0].Cells["multiplierColumn"].Value);
Assert.Equal(new object[] { "Has Been Cut", "To Be Cut", "Deleted" },
nests.Rows.Cast<DataGridViewRow>().Select(r => r.Cells["statusColumn"].Value));
},
TestTimeout,
"The releases view test did not complete.");
}
[Fact]
public void Browser_Release_ReadsTheSetWithItsMembers_OnlyWhenEnabled()
{
StaTestThread.Run(
() =>
{
var library = new PackageLibrary();
var a = library.Add("SYN-A", "Synthetic Steel");
var sets = new InMemoryDrawingSets();
CreateSet(sets, (a, 2));
var released = new List<DrawingSetRecord>();
using var host = new Form();
var browser = new DrawingSetBrowser { Dock = DockStyle.Fill };
host.Controls.Add(browser);
browser.Initialize(sets, library, Server);
browser.ShowRelease = released.Add;
host.Show();
Run(browser.RefreshAsync());
var releaseButton = Field<Button>(browser, "releaseButton");
Assert.False(releaseButton.Visible);
browser.EnableRelease(new MemoryNests(), (_, _) => { });
Assert.True(releaseButton.Visible);
Assert.True(releaseButton.Enabled);
Run(browser.ReleaseSelectedAsync());
var set = Assert.Single(released);
Assert.Equal("SYN-A", Assert.Single(set.Members).Drawing!.Name);
},
TestTimeout,
"The browser release test did not complete.");
}
private static DrawingSetRecord CreateSet(InMemoryDrawingSets sets, params (DrawingLibraryRecord Drawing, int Quantity)[] members)
{
var task = sets.CreateAsync(new DrawingSetRecord
{
Id = Guid.NewGuid(),
Name = "Synthetic kit",
Customer = "Synthetic customer",
Members = members.Select(m => new DrawingSetMember { DrawingId = m.Drawing.Id, Quantity = m.Quantity, Drawing = m.Drawing }).ToList(),
});
return task.GetAwaiter().GetResult();
}
private static void Run(Task task) => PumpUntil(() => task.IsCompleted, "the operation to finish");
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (!condition() && DateTime.UtcNow < until)
{
Application.DoEvents();
Thread.Sleep(10);
}
Assert.True(condition(), $"Timed out waiting for {what}.");
}
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
/// <summary>Real drawing packages by revision, so the release reads them as the desktop does.</summary>
private sealed class PackageLibrary : IDrawingLibraryStore
{
private readonly List<DrawingLibraryRecord> _records = new();
private readonly Dictionary<(Guid, int), byte[]> _packages = new();
public DrawingLibraryRecord Add(string name, string material, bool archived = false)
{
var package = DrawingPackage.Write(new RectangleShape { Name = name, Length = 4, Width = 2 }.GetDrawing(), Units.Inches);
var record = new DrawingLibraryRecord
{
Id = Guid.NewGuid(),
Revision = 1,
Name = name,
MaterialName = material,
MaterialGrade = "Grade A",
Thickness = 0.25,
Units = Units.Inches,
CutType = CutType.Laser,
Archived = archived,
PackageSha256 = Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant(),
};
_records.Add(record);
_packages[(record.Id, record.Revision)] = package;
return record;
}
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
Task.FromResult(_packages.TryGetValue((id, revision), out var package) ? package : null);
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
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();
public Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord> UploadAsync(DrawingLibraryRecord record, byte[] package,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
/// <summary>Stores uploads in memory under their client-allocated ids.</summary>
private sealed class MemoryNests : INestRepository
{
public List<NestRecord> Uploaded { get; } = new();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default)
{
Assert.NotEmpty(file);
var stored = new NestRecord { Id = record.Id, Revision = 1, Name = record.Name, Status = record.Status };
Uploaded.Add(stored);
return Task.FromResult(stored);
}
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) =>
Task.FromResult(Uploaded.FirstOrDefault(r => r.Id == id));
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
}
}
+31 -1
View File
@@ -44,6 +44,8 @@ namespace OpenNest.Controls
editButton = new System.Windows.Forms.Button();
archiveButton = new System.Windows.Forms.Button();
historyButton = new System.Windows.Forms.Button();
releasesButton = new System.Windows.Forms.Button();
releaseButton = new System.Windows.Forms.Button();
statusLabel = new System.Windows.Forms.Label();
searchTimer = new System.Windows.Forms.Timer(components);
layoutTable.SuspendLayout();
@@ -212,7 +214,9 @@ namespace OpenNest.Controls
// bottomTable
//
bottomTable.AutoSize = true;
bottomTable.ColumnCount = 7;
bottomTable.ColumnCount = 9;
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.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
@@ -227,6 +231,8 @@ namespace OpenNest.Controls
bottomTable.Controls.Add(editButton, 4, 0);
bottomTable.Controls.Add(archiveButton, 5, 0);
bottomTable.Controls.Add(historyButton, 6, 0);
bottomTable.Controls.Add(releasesButton, 7, 0);
bottomTable.Controls.Add(releaseButton, 8, 0);
bottomTable.Dock = System.Windows.Forms.DockStyle.Fill;
bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
bottomTable.Name = "bottomTable";
@@ -299,6 +305,28 @@ namespace OpenNest.Controls
historyButton.UseVisualStyleBackColor = true;
historyButton.Click += HistoryButton_Click;
//
// releasesButton
//
releasesButton.AutoSize = true;
releasesButton.Enabled = false;
releasesButton.Name = "releasesButton";
releasesButton.TabIndex = 7;
releasesButton.Text = "Releases...";
releasesButton.UseVisualStyleBackColor = true;
releasesButton.Click += ReleasesButton_Click;
//
// releaseButton
//
releaseButton.AutoSize = true;
releaseButton.Enabled = false;
releaseButton.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3);
releaseButton.Name = "releaseButton";
releaseButton.TabIndex = 8;
releaseButton.Text = "Release...";
releaseButton.UseVisualStyleBackColor = true;
releaseButton.Visible = false;
releaseButton.Click += ReleaseButton_Click;
//
// statusLabel
//
statusLabel.AutoEllipsis = true;
@@ -357,6 +385,8 @@ namespace OpenNest.Controls
private System.Windows.Forms.Button editButton;
private System.Windows.Forms.Button archiveButton;
private System.Windows.Forms.Button historyButton;
private System.Windows.Forms.Button releasesButton;
private System.Windows.Forms.Button releaseButton;
private System.Windows.Forms.Label statusLabel;
private System.Windows.Forms.Timer searchTimer;
}
+93 -1
View File
@@ -21,6 +21,10 @@ namespace OpenNest.Controls
private const string LoadingText = "Loading...";
private IDrawingSetStore sets;
private IDrawingLibraryStore library;
private string serverUrl;
private INestRepository nests;
private Action<Nest, NestRecord> openNest;
private DrawingSetBrowseSession session;
private bool pick;
private bool busy;
@@ -34,6 +38,12 @@ namespace OpenNest.Controls
/// <summary>Shows a set's revision history; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowHistory { get; set; }
/// <summary>Shows a set's releases; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowReleases { get; set; }
/// <summary>Shows the release dialog for a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowRelease { get; set; }
/// <summary>Pick mode: an active set was double-clicked (or chosen with Enter).</summary>
public event EventHandler SetActivated;
@@ -69,7 +79,8 @@ namespace OpenNest.Controls
if (session != null)
throw new InvalidOperationException("The set browser is already initialized.");
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
ArgumentNullException.ThrowIfNull(library);
this.library = library ?? throw new ArgumentNullException(nameof(library));
this.serverUrl = serverUrl ?? "";
this.pick = pick;
session = new DrawingSetBrowseSession(sets);
ShowMessage = (text, buttons, icon) => MessageBox.Show(FindForm(), text, FindForm()?.Text ?? "Drawing Sets", buttons, icon);
@@ -83,6 +94,16 @@ namespace OpenNest.Controls
using var form = new DrawingSetHistoryForm(sets, set);
form.ShowDialog(FindForm());
};
ShowReleases = set =>
{
using var form = new SetReleasesForm(sets, set);
form.ShowDialog(FindForm());
};
ShowRelease = set =>
{
using var form = new ReleaseSetForm(sets, library, nests, this.serverUrl, set, openNest);
form.ShowDialog(FindForm());
};
if (pick)
{
@@ -91,12 +112,27 @@ namespace OpenNest.Controls
editButton.Visible = false;
archiveButton.Visible = false;
historyButton.Visible = false;
releasesButton.Visible = false;
}
UpdateNavigation();
UpdateCommands();
}
/// <summary>
/// Shows Release, which makes nests from the selected set: uploads them through
/// <paramref name="nests"/> and opens each with <paramref name="openNest"/>.
/// </summary>
public void EnableRelease(INestRepository nests, Action<Nest, NestRecord> openNest)
{
if (session == null || pick)
throw new InvalidOperationException("Release needs an initialized set browser that is not picking.");
this.nests = nests ?? throw new ArgumentNullException(nameof(nests));
this.openNest = openNest ?? throw new ArgumentNullException(nameof(openNest));
releaseButton.Visible = true;
UpdateCommands();
}
/// <summary>Re-reads the current page (the first call loads the list).</summary>
public Task RefreshAsync()
{
@@ -179,6 +215,56 @@ namespace OpenNest.Controls
ShowHistory(set);
}
private void ReleasesButton_Click(object sender, EventArgs e)
{
if (SelectedSet is { } set)
ShowReleases(set);
}
private async void ReleaseButton_Click(object sender, EventArgs e) => await ReleaseSelectedAsync();
/// <summary>Reads the selected active set with its members' current drawings and opens the release dialog.</summary>
internal async Task ReleaseSelectedAsync()
{
var listed = SelectedSet;
if (session == null || nests == null || listed == null || listed.Archived || busy)
return;
DrawingSetRecord current;
busy = true;
UpdateCommands();
statusLabel.Text = $"Reading set '{listed.Name}'...";
try
{
current = await sets.GetAsync(listed.Id);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
Fail($"Could not read set '{listed.Name}': {ex.Message}");
return;
}
finally
{
busy = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return;
statusLabel.Text = "";
if (current == null || current.Archived)
{
ShowMessage(current == null
? $"Set '{listed.Name}' no longer exists on the server."
: $"Set '{listed.Name}' was archived. Restore it to release it.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await RunAsync(session.RefreshAsync);
return;
}
ShowRelease(current);
}
private void ActivateSelected()
{
if (SelectedSet is { Archived: false })
@@ -358,6 +444,10 @@ namespace OpenNest.Controls
setGrid.Rows.Add(row);
}
// The grid only sometimes makes the first row current by itself (it depends on focus).
if (setGrid.CurrentCell == null && setGrid.Rows.Count > 0)
setGrid.CurrentCell = setGrid.Rows[0].Cells[0];
foreach (DataGridViewColumn column in setGrid.Columns)
{
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSetSortField field && field == session.Sort
@@ -383,6 +473,8 @@ namespace OpenNest.Controls
archiveButton.Enabled = ready && selected != null;
archiveButton.Text = selected is { Archived: true } ? "Restore" : "Archive";
historyButton.Enabled = ready && selected != null;
releasesButton.Enabled = ready && selected != null;
releaseButton.Enabled = ready && nests != null && selected is { Archived: false, MemberCount: > 0 };
}
}
}
+10
View File
@@ -194,6 +194,16 @@ namespace OpenNest.Forms
/// <summary>The Sets tab's browser; loaded the first time the tab is shown.</summary>
internal OpenNest.Controls.DrawingSetBrowser SetBrowser => setBrowser;
/// <summary>
/// Lets the Sets tab release sets: nests are uploaded through <paramref name="nests"/>
/// and each is opened with <paramref name="openNest"/>.
/// </summary>
public void EnableRelease(INestRepository nests, Action<Nest, NestRecord> openNest)
{
if (setBrowser.IsInitialized)
setBrowser.EnableRelease(nests, openNest);
}
private async void TabControl_SelectedIndexChanged(object sender, EventArgs e)
{
if (tabControl.SelectedTab == setsTab && setBrowser.IsInitialized && !setBrowser.HasLoaded)
+4
View File
@@ -84,6 +84,10 @@ namespace OpenNest.Forms
revisionGrid.Rows.Add(row);
}
// The grid only sometimes makes the first row current by itself (it depends on focus).
if (revisionGrid.CurrentCell == null && revisionGrid.Rows.Count > 0)
revisionGrid.CurrentCell = revisionGrid.Rows[0].Cells[0];
summaryLabel.Text = loaded.Items.Count == 0
? $"No revisions on this page ({loaded.Total:N0} in total)."
: $"Revisions {offset + 1:N0}-{offset + loaded.Items.Count:N0} of {loaded.Total:N0}";
+28
View File
@@ -757,6 +757,7 @@ namespace OpenNest.Forms
{
var form = new EditNestInfoForm { AllowRename = NestStorage.Settings.IsDatabaseMode };
form.LoadNestInfo(Nest);
_ = ShowReleaseOriginAsync(form);
while (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
@@ -772,6 +773,30 @@ namespace OpenNest.Forms
}
}
/// <summary>
/// Shows which drawing set release made this nest, when it is a server nest that came
/// from one. Best effort and in the background: Nest Info never waits for the server.
/// </summary>
private async Task ShowReleaseOriginAsync(EditNestInfoForm form)
{
var serverUrl = NestStorage.Settings.ServerUrl;
if (!NestStorage.Settings.IsDatabaseMode || Document.RemoteId == Guid.Empty || Document.IsFirstDatabaseSave(serverUrl))
return;
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var release = await NestStorage.CreateDrawingSetStore().GetReleaseForNestAsync(Document.RemoteId, timeout.Token);
if (release != null && !form.IsDisposed)
form.ReleaseOrigin = SetReleaseText.Origin(release);
}
catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException
or OperationCanceledException or ArgumentException or System.Text.Json.JsonException)
{
// An older server, or none reachable: the dialog simply shows no origin.
}
}
/// <summary>Renames go up with the next Save; warn now about a duplicate name.</summary>
private async Task<bool> ConfirmRenameAsync(string name)
{
@@ -1297,6 +1322,9 @@ namespace OpenNest.Forms
}
using var form = new DrawingLibraryForm(library, NestStorage.Settings.ServerUrl, Nest, sets);
// Released nests open in their own tabs beside this one.
if (MdiParent is MainForm main)
form.EnableRelease(NestStorage.CreateRepository(), main.OpenServerNest);
form.ShowDialog(this);
if (form.AddedDrawings.Count > 0)
UpdateDrawingList();
+14
View File
@@ -74,6 +74,7 @@
this.cancelButton = new System.Windows.Forms.Button();
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
this.applyButton = new System.Windows.Forms.Button();
this.releaseLabel = new System.Windows.Forms.Label();
this.groupBox1.SuspendLayout();
this.tableLayoutPanel2.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.leftSpacingBox)).BeginInit();
@@ -667,6 +668,7 @@
//
// bottomPanel1
//
this.bottomPanel1.Controls.Add(this.releaseLabel);
this.bottomPanel1.Controls.Add(this.applyButton);
this.bottomPanel1.Controls.Add(this.cancelButton);
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
@@ -675,6 +677,17 @@
this.bottomPanel1.Size = new System.Drawing.Size(659, 50);
this.bottomPanel1.TabIndex = 1;
//
// releaseLabel
//
this.releaseLabel.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.releaseLabel.AutoEllipsis = true;
this.releaseLabel.ForeColor = System.Drawing.SystemColors.GrayText;
this.releaseLabel.Location = new System.Drawing.Point(12, 16);
this.releaseLabel.Name = "releaseLabel";
this.releaseLabel.Size = new System.Drawing.Size(440, 18);
this.releaseLabel.TabIndex = 2;
//
// applyButton
//
this.applyButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
@@ -753,6 +766,7 @@
private System.Windows.Forms.GroupBox groupBox3;
private Controls.QuadrantSelect quadrantSelect1;
private System.Windows.Forms.Button applyButton;
private System.Windows.Forms.Label releaseLabel;
private System.Windows.Forms.Button cancelButton;
private System.Windows.Forms.TabControl tabControl1;
private System.Windows.Forms.TabPage tabPage1;
+10
View File
@@ -52,6 +52,16 @@ namespace OpenNest.Forms
set { nameBox.ReadOnly = !value; }
}
/// <summary>
/// The drawing set release this nest came from (Database mode), shown beside the
/// buttons; blank for none.
/// </summary>
public string ReleaseOrigin
{
get { return releaseLabel.Text; }
set { releaseLabel.Text = value ?? ""; }
}
public string Customer
{
get { return customerBox.Text; }
+12
View File
@@ -33,6 +33,18 @@ namespace OpenNest.Forms
editForm.PlateView.ZoomToFit();
}
/// <summary>
/// Opens a nest that is already on the nest server (a drawing set release made it) in
/// its own tab, bound to that record so the next Save updates it.
/// </summary>
internal void OpenServerNest(Nest nest, NestRecord record)
{
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(record);
var windowState = ActiveMdiChild?.WindowState ?? FormWindowState.Maximized;
LoadNest(nest, windowState, record.Id, NestStorage.Settings.ServerUrl, record.Revision);
}
#region File Menu Events
private async void New_Click(object sender, EventArgs e)
+9 -4
View File
@@ -19,9 +19,13 @@ namespace OpenNest.Forms
{
private static readonly TimeSpan ServerTimeout = TimeSpan.FromSeconds(3);
public static async Task<Nest> CreateAsync(IWin32Window owner)
/// <param name="units">
/// The new nest's units, with the saved plate defaults converted to them (a drawing set
/// release group drawn in other units); null uses the defaults as saved.
/// </param>
public static async Task<Nest> CreateAsync(IWin32Window owner, Units? units = null)
{
var nest = CreateDefaultNest(owner);
var nest = CreateDefaultNest(owner, units);
var now = DateTime.Now;
nest.DateCreated = now;
@@ -30,10 +34,11 @@ namespace OpenNest.Forms
return nest;
}
private static Nest CreateDefaultNest(IWin32Window owner)
private static Nest CreateDefaultNest(IWin32Window owner, Units? units)
{
var nest = new Nest();
MachineSettings.LoadNestDefaults().ApplyTo(nest);
var defaults = MachineSettings.LoadNestDefaults();
(units is { } converted ? defaults.ConvertedTo(converted) : defaults).ApplyTo(nest);
// After the defaults so the rail is converted to the new nest's units.
MachineSettings.LoadClampSettings(owner).ApplyToNewNest(nest);
return nest;
+359
View File
@@ -0,0 +1,359 @@
namespace OpenNest.Forms
{
partial class ReleaseSetForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing)
{
cancellation?.Dispose();
if (components != null)
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
System.Windows.Forms.DataGridViewCellStyle rightAligned = new System.Windows.Forms.DataGridViewCellStyle();
layoutTable = new System.Windows.Forms.TableLayoutPanel();
setLabel = new System.Windows.Forms.Label();
optionsTable = new System.Windows.Forms.TableLayoutPanel();
multiplierLabel = new System.Windows.Forms.Label();
multiplierBox = new System.Windows.Forms.NumericUpDown();
includeArchivedCheckBox = new System.Windows.Forms.CheckBox();
notesLabel = new System.Windows.Forms.Label();
notesBox = new System.Windows.Forms.TextBox();
groupGrid = new System.Windows.Forms.DataGridView();
includeColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
materialColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
gradeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
thicknessColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
unitsColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
cutTypeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
drawingsColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
partsColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
statusColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
skippedLabel = new System.Windows.Forms.Label();
bottomTable = new System.Windows.Forms.TableLayoutPanel();
summaryLabel = new System.Windows.Forms.Label();
releaseButton = new System.Windows.Forms.Button();
closeButton = new System.Windows.Forms.Button();
statusLabel = new System.Windows.Forms.Label();
layoutTable.SuspendLayout();
optionsTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)multiplierBox).BeginInit();
((System.ComponentModel.ISupportInitialize)groupGrid).BeginInit();
bottomTable.SuspendLayout();
SuspendLayout();
//
// layoutTable
//
layoutTable.ColumnCount = 1;
layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.Controls.Add(setLabel, 0, 0);
layoutTable.Controls.Add(optionsTable, 0, 1);
layoutTable.Controls.Add(groupGrid, 0, 2);
layoutTable.Controls.Add(skippedLabel, 0, 3);
layoutTable.Controls.Add(bottomTable, 0, 4);
layoutTable.Controls.Add(statusLabel, 0, 5);
layoutTable.Dock = System.Windows.Forms.DockStyle.Fill;
layoutTable.Location = new System.Drawing.Point(0, 0);
layoutTable.Name = "layoutTable";
layoutTable.Padding = new System.Windows.Forms.Padding(8);
layoutTable.RowCount = 6;
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.Size = new System.Drawing.Size(944, 521);
layoutTable.TabIndex = 0;
//
// setLabel
//
setLabel.AutoEllipsis = true;
setLabel.Dock = System.Windows.Forms.DockStyle.Fill;
setLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
setLabel.Margin = new System.Windows.Forms.Padding(3, 0, 3, 6);
setLabel.Name = "setLabel";
setLabel.Size = new System.Drawing.Size(100, 20);
setLabel.TabIndex = 0;
//
// optionsTable
//
optionsTable.AutoSize = true;
optionsTable.ColumnCount = 5;
optionsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
optionsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
optionsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
optionsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
optionsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
optionsTable.Controls.Add(multiplierLabel, 0, 0);
optionsTable.Controls.Add(multiplierBox, 1, 0);
optionsTable.Controls.Add(includeArchivedCheckBox, 2, 0);
optionsTable.Controls.Add(notesLabel, 3, 0);
optionsTable.Controls.Add(notesBox, 4, 0);
optionsTable.Dock = System.Windows.Forms.DockStyle.Fill;
optionsTable.Margin = new System.Windows.Forms.Padding(0, 0, 0, 6);
optionsTable.Name = "optionsTable";
optionsTable.RowCount = 1;
optionsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
optionsTable.TabIndex = 1;
//
// multiplierLabel
//
multiplierLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
multiplierLabel.AutoSize = true;
multiplierLabel.Name = "multiplierLabel";
multiplierLabel.TabIndex = 0;
multiplierLabel.Text = "Kits (multiplier):";
//
// multiplierBox
//
multiplierBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
multiplierBox.Maximum = new decimal(new int[] { 1000, 0, 0, 0 });
multiplierBox.Minimum = new decimal(new int[] { 1, 0, 0, 0 });
multiplierBox.Name = "multiplierBox";
multiplierBox.Size = new System.Drawing.Size(70, 25);
multiplierBox.TabIndex = 1;
multiplierBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
multiplierBox.Value = new decimal(new int[] { 1, 0, 0, 0 });
multiplierBox.ValueChanged += Options_Changed;
//
// includeArchivedCheckBox
//
includeArchivedCheckBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
includeArchivedCheckBox.AutoSize = true;
includeArchivedCheckBox.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3);
includeArchivedCheckBox.Name = "includeArchivedCheckBox";
includeArchivedCheckBox.TabIndex = 2;
includeArchivedCheckBox.Text = "Include archived drawings";
includeArchivedCheckBox.UseVisualStyleBackColor = true;
includeArchivedCheckBox.CheckedChanged += Options_Changed;
//
// notesLabel
//
notesLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
notesLabel.AutoSize = true;
notesLabel.Margin = new System.Windows.Forms.Padding(12, 0, 3, 0);
notesLabel.Name = "notesLabel";
notesLabel.TabIndex = 3;
notesLabel.Text = "Release notes:";
//
// notesBox
//
notesBox.Dock = System.Windows.Forms.DockStyle.Fill;
notesBox.MaxLength = 4000;
notesBox.Name = "notesBox";
notesBox.TabIndex = 4;
//
// groupGrid
//
groupGrid.AllowUserToAddRows = false;
groupGrid.AllowUserToDeleteRows = false;
groupGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { includeColumn, materialColumn, gradeColumn, thicknessColumn, unitsColumn, cutTypeColumn, drawingsColumn, partsColumn, statusColumn });
groupGrid.Dock = System.Windows.Forms.DockStyle.Fill;
groupGrid.MultiSelect = false;
groupGrid.Name = "groupGrid";
groupGrid.RowHeadersVisible = false;
groupGrid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
groupGrid.StandardTab = true;
groupGrid.TabIndex = 2;
groupGrid.CellValueChanged += GroupGrid_CellValueChanged;
groupGrid.CurrentCellDirtyStateChanged += GroupGrid_CurrentCellDirtyStateChanged;
//
// includeColumn
//
includeColumn.HeaderText = "Release";
includeColumn.Name = "includeColumn";
includeColumn.Width = 65;
//
// materialColumn
//
materialColumn.HeaderText = "Material";
materialColumn.Name = "materialColumn";
materialColumn.ReadOnly = true;
materialColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
materialColumn.Width = 140;
//
// gradeColumn
//
gradeColumn.HeaderText = "Grade";
gradeColumn.Name = "gradeColumn";
gradeColumn.ReadOnly = true;
gradeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
gradeColumn.Width = 90;
//
// thicknessColumn
//
rightAligned.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight;
thicknessColumn.DefaultCellStyle = rightAligned;
thicknessColumn.HeaderText = "Thickness";
thicknessColumn.Name = "thicknessColumn";
thicknessColumn.ReadOnly = true;
thicknessColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
thicknessColumn.Width = 80;
//
// unitsColumn
//
unitsColumn.HeaderText = "Units";
unitsColumn.Name = "unitsColumn";
unitsColumn.ReadOnly = true;
unitsColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
unitsColumn.Width = 55;
//
// cutTypeColumn
//
cutTypeColumn.HeaderText = "Cut type";
cutTypeColumn.Name = "cutTypeColumn";
cutTypeColumn.ReadOnly = true;
cutTypeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
cutTypeColumn.Width = 75;
//
// drawingsColumn
//
drawingsColumn.DefaultCellStyle = rightAligned;
drawingsColumn.HeaderText = "Drawings";
drawingsColumn.Name = "drawingsColumn";
drawingsColumn.ReadOnly = true;
drawingsColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
drawingsColumn.Width = 70;
//
// partsColumn
//
partsColumn.DefaultCellStyle = rightAligned;
partsColumn.HeaderText = "Parts";
partsColumn.Name = "partsColumn";
partsColumn.ReadOnly = true;
partsColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
partsColumn.Width = 65;
//
// statusColumn
//
statusColumn.AutoSizeMode = System.Windows.Forms.DataGridViewAutoSizeColumnMode.Fill;
statusColumn.HeaderText = "Status";
statusColumn.Name = "statusColumn";
statusColumn.ReadOnly = true;
statusColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// skippedLabel
//
skippedLabel.AutoSize = true;
skippedLabel.ForeColor = System.Drawing.SystemColors.GrayText;
skippedLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0);
skippedLabel.MaximumSize = new System.Drawing.Size(920, 0);
skippedLabel.Name = "skippedLabel";
skippedLabel.TabIndex = 3;
skippedLabel.Visible = false;
//
// bottomTable
//
bottomTable.AutoSize = true;
bottomTable.ColumnCount = 3;
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.Controls.Add(summaryLabel, 0, 0);
bottomTable.Controls.Add(releaseButton, 1, 0);
bottomTable.Controls.Add(closeButton, 2, 0);
bottomTable.Dock = System.Windows.Forms.DockStyle.Fill;
bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
bottomTable.Name = "bottomTable";
bottomTable.RowCount = 1;
bottomTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
bottomTable.TabIndex = 4;
//
// summaryLabel
//
summaryLabel.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
summaryLabel.AutoEllipsis = true;
summaryLabel.Name = "summaryLabel";
summaryLabel.TabIndex = 0;
//
// releaseButton
//
releaseButton.AutoSize = true;
releaseButton.Enabled = false;
releaseButton.Name = "releaseButton";
releaseButton.TabIndex = 1;
releaseButton.Text = "Release";
releaseButton.UseVisualStyleBackColor = true;
releaseButton.Click += ReleaseButton_Click;
//
// closeButton
//
closeButton.AutoSize = true;
closeButton.Name = "closeButton";
closeButton.TabIndex = 2;
closeButton.Text = "Close";
closeButton.UseVisualStyleBackColor = true;
closeButton.Click += CloseButton_Click;
//
// statusLabel
//
statusLabel.AutoEllipsis = true;
statusLabel.Dock = System.Windows.Forms.DockStyle.Fill;
statusLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0);
statusLabel.Name = "statusLabel";
statusLabel.Size = new System.Drawing.Size(100, 20);
statusLabel.TabIndex = 5;
//
// ReleaseSetForm
//
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
ClientSize = new System.Drawing.Size(944, 521);
Controls.Add(layoutTable);
Font = new System.Drawing.Font("Segoe UI", 9.75F);
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(760, 400);
Name = "ReleaseSetForm";
ShowIcon = false;
ShowInTaskbar = false;
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Release Drawing Set";
FormClosing += ReleaseSetForm_FormClosing;
layoutTable.ResumeLayout(false);
layoutTable.PerformLayout();
optionsTable.ResumeLayout(false);
optionsTable.PerformLayout();
((System.ComponentModel.ISupportInitialize)multiplierBox).EndInit();
((System.ComponentModel.ISupportInitialize)groupGrid).EndInit();
bottomTable.ResumeLayout(false);
bottomTable.PerformLayout();
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TableLayoutPanel layoutTable;
private System.Windows.Forms.Label setLabel;
private System.Windows.Forms.TableLayoutPanel optionsTable;
private System.Windows.Forms.Label multiplierLabel;
private System.Windows.Forms.NumericUpDown multiplierBox;
private System.Windows.Forms.CheckBox includeArchivedCheckBox;
private System.Windows.Forms.Label notesLabel;
private System.Windows.Forms.TextBox notesBox;
private System.Windows.Forms.DataGridView groupGrid;
private System.Windows.Forms.DataGridViewCheckBoxColumn includeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn materialColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn gradeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn thicknessColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn unitsColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn cutTypeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn drawingsColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn partsColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn statusColumn;
private System.Windows.Forms.Label skippedLabel;
private System.Windows.Forms.TableLayoutPanel bottomTable;
private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.Button releaseButton;
private System.Windows.Forms.Button closeButton;
private System.Windows.Forms.Label statusLabel;
}
}
+329
View File
@@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.IO;
namespace OpenNest.Forms
{
/// <summary>
/// Drawing Sets &gt; Release: previews how a set splits into one nest per material group
/// (<see cref="SetReleasePlan"/>), with a kit multiplier, an archived-drawing toggle and a
/// tick per group, then makes those nests through <see cref="SetReleaseSession"/>: each is
/// uploaded as a Draft and opened in its own tab, and the release is recorded once every
/// chosen nest exists. The dialog shows every group's outcome; a release runs once per
/// dialog.
/// </summary>
public partial class ReleaseSetForm : Form
{
private readonly IDrawingSetStore sets;
private readonly IDrawingLibrary library;
private readonly INestRepository nests;
private readonly string serverUrl;
private readonly DrawingSetRecord set;
private readonly Action<Nest, NestRecord> openNest;
private readonly HashSet<string> excluded = new(StringComparer.Ordinal);
private SetReleasePlan plan;
private SetReleaseSession session;
private CancellationTokenSource cancellation;
private bool populating;
private bool running;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>Makes the empty nest for a group, as File &gt; New would in its units; replaced by tests.</summary>
internal Func<SetReleaseGroup, CancellationToken, Task<Nest>> CreateNest { get; set; }
/// <summary>The finished release, once one has run; otherwise null.</summary>
public SetReleaseResult Result { get; private set; }
/// <param name="set">The set read with <see cref="IDrawingSetStore.GetAsync"/>, members joined.</param>
/// <param name="openNest">Opens a created nest in its own tab, bound to its server record.</param>
public ReleaseSetForm(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl,
DrawingSetRecord set, Action<Nest, NestRecord> openNest)
{
InitializeComponent();
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
this.library = library ?? throw new ArgumentNullException(nameof(library));
this.nests = nests ?? throw new ArgumentNullException(nameof(nests));
this.serverUrl = serverUrl ?? "";
this.set = set ?? throw new ArgumentNullException(nameof(set));
this.openNest = openNest ?? throw new ArgumentNullException(nameof(openNest));
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
CreateNest = (group, _) => NewNestFactory.CreateAsync(this, group.Units);
GridStyle.Apply(groupGrid);
Text = $"Release '{set.Name}'";
setLabel.Text = string.IsNullOrWhiteSpace(set.Customer)
? $"{set.Name} — revision {set.Revision}"
: $"{set.Name} ({set.Customer}) — revision {set.Revision}";
BuildPlan();
}
private void Options_Changed(object sender, EventArgs e)
{
if (!running && Result == null)
BuildPlan();
}
// Commit a checkbox click at once so the summary follows the tick.
private void GroupGrid_CurrentCellDirtyStateChanged(object sender, EventArgs e)
{
if (groupGrid.IsCurrentCellDirty && groupGrid.CurrentCell?.OwningColumn == includeColumn)
groupGrid.CommitEdit(DataGridViewDataErrorContexts.Commit);
}
private void GroupGrid_CellValueChanged(object sender, DataGridViewCellEventArgs e)
{
if (populating || e.RowIndex < 0 || e.ColumnIndex != includeColumn.Index)
return;
var row = groupGrid.Rows[e.RowIndex];
if (row.Tag is SetReleaseGroup group)
{
if (row.Cells[includeColumn.Index].Value is true)
excluded.Remove(group.Key);
else
excluded.Add(group.Key);
}
UpdateCommands();
}
private async void ReleaseButton_Click(object sender, EventArgs e)
{
if (Result is { Release: null, ReleaseError.Length: > 0 })
await RetryRecordAsync();
else
await ReleaseAsync();
}
private void CloseButton_Click(object sender, EventArgs e)
{
if (running)
{
cancellation?.Cancel();
statusLabel.Text = "Stopping after the nest being uploaded...";
closeButton.Enabled = false;
return;
}
Close();
}
private void ReleaseSetForm_FormClosing(object sender, FormClosingEventArgs e)
{
// Nests may be on their way to the server; stop between uploads, never mid-way.
if (!running)
return;
e.Cancel = true;
cancellation?.Cancel();
}
/// <summary>The groups ticked for release, in plan order.</summary>
internal IReadOnlyList<SetReleaseGroup> ChosenGroups() =>
plan.Groups.Where(g => g.CanRelease && !excluded.Contains(g.Key)).ToList();
/// <summary>
/// Confirms, then makes one Draft nest per ticked group, opens each in its own tab and
/// records the release. Every group's outcome is shown in the grid.
/// </summary>
internal async Task ReleaseAsync()
{
if (running || Result != null || !groupGrid.EndEdit())
return;
var chosen = ChosenGroups();
if (chosen.Count == 0)
return;
var multiplier = plan.Multiplier;
if (ShowMessage($"Create {chosen.Count} Draft nest{(chosen.Count == 1 ? "" : "s")} from set '{set.Name}'"
+ (multiplier == 1 ? "" : $" ×{multiplier}") + "? Each opens in its own tab.",
MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
return;
running = true;
cancellation = new CancellationTokenSource();
closeButton.Text = "Cancel";
UpdateCommands();
session = new SetReleaseSession(sets, library, nests, serverUrl, ReadPackage, WriteNest, CreateNest);
var progress = new Progress<SetReleaseProgress>(ShowProgress);
try
{
Result = await session.RunAsync(plan, chosen, Environment.UserName, notesBox.Text, progress, cancellation.Token);
}
finally
{
running = false;
if (!IsDisposed)
{
closeButton.Text = "Close";
closeButton.Enabled = true;
}
}
foreach (var created in Result.Created)
openNest(created.Nest, created.Record);
if (IsDisposed)
return;
ShowOutcome();
}
/// <summary>Posts the release again after every nest was created but recording it failed.</summary>
internal async Task RetryRecordAsync()
{
if (running || session == null || !session.HasPendingRelease)
return;
running = true;
UpdateCommands();
statusLabel.Text = "Recording the release...";
try
{
var release = await session.RetryReleaseAsync();
Result = Result.WithRelease(release);
}
catch (Exception ex) when (ex is IOException || ex is System.Net.Http.HttpRequestException
|| ex is TaskCanceledException || ex is System.Text.Json.JsonException)
{
if (!IsDisposed)
ShowMessage($"The release is still not recorded: {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
finally
{
running = false;
}
if (!IsDisposed)
ShowOutcome();
}
private void BuildPlan()
{
plan = SetReleasePlan.Build(set, (int)multiplierBox.Value, includeArchivedCheckBox.Checked);
populating = true;
groupGrid.Rows.Clear();
foreach (var group in plan.Groups)
{
var row = new DataGridViewRow { Tag = group };
row.CreateCells(groupGrid,
group.CanRelease && !excluded.Contains(group.Key),
group.IsUnassigned ? "(none)" : group.MaterialName,
group.MaterialGrade,
group.IsUnassigned ? "" : group.Thickness.ToString("0.####", CultureInfo.CurrentCulture),
group.IsUnassigned ? "" : UnitsHelper.GetShortString(group.Units),
group.IsUnassigned ? "" : group.CutType.ToString(),
group.Items.Count,
group.TotalQuantity,
group.CanRelease ? string.Join(" ", group.Warnings) : group.Refusal);
// A refused group cannot be ticked.
row.Cells[includeColumn.Index].ReadOnly = !group.CanRelease;
groupGrid.Rows.Add(row);
}
populating = false;
var skipped = plan.SkippedArchived;
skippedLabel.Visible = skipped.Count > 0;
skippedLabel.Text = skipped.Count == 0 ? "" :
$"{skipped.Count} archived drawing{(skipped.Count == 1 ? " is" : "s are")} left out: "
+ string.Join(", ", skipped.Take(5).Select(m => $"'{m.Drawing!.Name}'"))
+ (skipped.Count > 5 ? $" and {skipped.Count - 5} more" : "")
+ ". Tick Include archived drawings to release them.";
UpdateCommands();
}
private void ShowProgress(SetReleaseProgress progress)
{
// Progress is posted, so a report can arrive after the run ended; the outcome wins.
if (IsDisposed || !running)
return;
statusLabel.Text = progress.Stage switch
{
SetReleaseStage.Preparing => $"Preparing nest {progress.Index + 1} of {progress.Count}: {Describe(progress.Group)}...",
SetReleaseStage.Uploading => $"Uploading nest {progress.Index + 1} of {progress.Count}: {Describe(progress.Group)}...",
_ => "Recording the release...",
};
}
private void ShowOutcome()
{
foreach (DataGridViewRow row in groupGrid.Rows)
{
var outcome = Result.Groups.FirstOrDefault(g => ReferenceEquals(g.Group, row.Tag));
if (outcome == null)
continue;
row.Cells[statusColumn.Index].Value = outcome.Outcome switch
{
SetReleaseGroupOutcome.Created => $"Created {outcome.Record!.Name}"
+ (outcome.Warnings.Count == 0 ? "" : " — " + string.Join(" ", outcome.Warnings)),
SetReleaseGroupOutcome.Refused => "Refused: " + outcome.Error,
SetReleaseGroupOutcome.Failed => "Failed: " + outcome.Error,
_ => "Not started",
};
}
var created = Result.Created.Count;
var nestsText = $"{created} nest{(created == 1 ? "" : "s")}";
if (Result.Release != null)
statusLabel.Text = $"Released: {nestsText} created and opened, and the release recorded.";
else if (Result.ReleaseError.Length > 0)
statusLabel.Text = Result.ReleaseError + " Choose Record Release to try again.";
else if (Result.Canceled)
statusLabel.Text = $"Canceled: {nestsText} created; no release recorded.";
else
statusLabel.Text = created == 0
? "Nothing was created. See the Status column."
: $"Stopped: {nestsText} created; no release recorded. See the Status column.";
UpdateCommands();
}
private void UpdateCommands()
{
var chosen = plan == null ? Array.Empty<SetReleaseGroup>() : ChosenGroups();
var editable = !running && Result == null;
multiplierBox.Enabled = editable;
includeArchivedCheckBox.Enabled = editable;
notesBox.ReadOnly = !editable;
includeColumn.ReadOnly = !editable;
if (editable)
{
foreach (DataGridViewRow row in groupGrid.Rows)
row.Cells[includeColumn.Index].ReadOnly = row.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));
var parts = chosen.Sum(g => (long)g.TotalQuantity);
summaryLabel.Text = plan == null || plan.Groups.Count == 0
? "This set has nothing to release."
: $"{chosen.Count} of {plan.Groups.Count} group{(plan.Groups.Count == 1 ? "" : "s")} chosen: "
+ $"{chosen.Count} Draft nest{(chosen.Count == 1 ? "" : "s")}, {parts:N0} part{(parts == 1 ? "" : "s")}";
}
private static string Describe(SetReleaseGroup group) =>
group == null ? "" : string.Join(" ", new[]
{
group.MaterialName,
group.MaterialGrade,
$"{group.Thickness.ToString("0.####", CultureInfo.CurrentCulture)} {UnitsHelper.GetShortString(group.Units)}",
}.Where(s => !string.IsNullOrWhiteSpace(s)));
private static LibraryPackage ReadPackage(byte[] bytes)
{
var contents = DrawingPackage.Read(bytes);
return new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings);
}
private static byte[] WriteNest(Nest nest)
{
using var stream = new MemoryStream();
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(stream);
return stream.ToArray();
}
}
}
+1 -8
View File
@@ -540,14 +540,7 @@ public sealed class SavedNestsForm : Form
UpdateTitle(session.Summary);
}
private static string FormatStatus(NestStatus status) => status switch
{
NestStatus.Draft => "Draft",
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
_ => status.ToString(),
};
private static string FormatStatus(NestStatus status) => NestStatuses.DisplayName(status);
private static string FormatDate(DateTime value) =>
value == default ? "" : value.ToString("g", CultureInfo.CurrentCulture);
+298
View File
@@ -0,0 +1,298 @@
namespace OpenNest.Forms
{
partial class SetReleasesForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
System.Windows.Forms.DataGridViewCellStyle rightAligned = new System.Windows.Forms.DataGridViewCellStyle();
layoutTable = new System.Windows.Forms.TableLayoutPanel();
releaseGrid = new System.Windows.Forms.DataGridView();
revisionColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
releasedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
releasedByColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
multiplierColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
nestsColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
progressColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
releaseNotesColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
nestsGroup = new System.Windows.Forms.GroupBox();
nestGrid = new System.Windows.Forms.DataGridView();
nestNameColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
groupColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
statusColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
nestCustomerColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
modifiedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
bottomTable = new System.Windows.Forms.TableLayoutPanel();
previousButton = new System.Windows.Forms.Button();
nextButton = new System.Windows.Forms.Button();
summaryLabel = new System.Windows.Forms.Label();
closeButton = new System.Windows.Forms.Button();
layoutTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)releaseGrid).BeginInit();
nestsGroup.SuspendLayout();
((System.ComponentModel.ISupportInitialize)nestGrid).BeginInit();
bottomTable.SuspendLayout();
SuspendLayout();
//
// layoutTable
//
layoutTable.ColumnCount = 1;
layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.Controls.Add(releaseGrid, 0, 0);
layoutTable.Controls.Add(nestsGroup, 0, 1);
layoutTable.Controls.Add(bottomTable, 0, 2);
layoutTable.Dock = System.Windows.Forms.DockStyle.Fill;
layoutTable.Location = new System.Drawing.Point(0, 0);
layoutTable.Name = "layoutTable";
layoutTable.Padding = new System.Windows.Forms.Padding(8);
layoutTable.RowCount = 3;
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.Size = new System.Drawing.Size(824, 541);
layoutTable.TabIndex = 0;
//
// releaseGrid
//
releaseGrid.AllowUserToAddRows = false;
releaseGrid.AllowUserToDeleteRows = false;
releaseGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { releasedColumn, releasedByColumn, revisionColumn, multiplierColumn, nestsColumn, progressColumn, releaseNotesColumn });
releaseGrid.Dock = System.Windows.Forms.DockStyle.Fill;
releaseGrid.MultiSelect = false;
releaseGrid.Name = "releaseGrid";
releaseGrid.ReadOnly = true;
releaseGrid.RowHeadersVisible = false;
releaseGrid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
releaseGrid.StandardTab = true;
releaseGrid.TabIndex = 0;
releaseGrid.CurrentCellChanged += ReleaseGrid_CurrentCellChanged;
//
// revisionColumn
//
rightAligned.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight;
revisionColumn.DefaultCellStyle = rightAligned;
revisionColumn.HeaderText = "Set revision";
revisionColumn.Name = "revisionColumn";
revisionColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
revisionColumn.Width = 70;
//
// releasedColumn
//
releasedColumn.HeaderText = "Released";
releasedColumn.Name = "releasedColumn";
releasedColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
releasedColumn.Width = 130;
//
// releasedByColumn
//
releasedByColumn.HeaderText = "Released by";
releasedByColumn.Name = "releasedByColumn";
releasedByColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
releasedByColumn.Width = 110;
//
// multiplierColumn
//
multiplierColumn.HeaderText = "Kits";
multiplierColumn.Name = "multiplierColumn";
multiplierColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
multiplierColumn.Width = 50;
multiplierColumn.DefaultCellStyle = rightAligned;
//
// nestsColumn
//
nestsColumn.HeaderText = "Nests";
nestsColumn.Name = "nestsColumn";
nestsColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
nestsColumn.Width = 55;
nestsColumn.DefaultCellStyle = rightAligned;
//
// progressColumn
//
progressColumn.HeaderText = "Progress";
progressColumn.Name = "progressColumn";
progressColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
progressColumn.Width = 160;
//
// releaseNotesColumn
//
releaseNotesColumn.HeaderText = "Notes";
releaseNotesColumn.Name = "releaseNotesColumn";
releaseNotesColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
releaseNotesColumn.AutoSizeMode = System.Windows.Forms.DataGridViewAutoSizeColumnMode.Fill;
//
// nestsGroup
//
nestsGroup.Controls.Add(nestGrid);
nestsGroup.Dock = System.Windows.Forms.DockStyle.Fill;
nestsGroup.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
nestsGroup.Name = "nestsGroup";
nestsGroup.TabIndex = 1;
nestsGroup.TabStop = false;
nestsGroup.Text = "Nests of the selected release";
//
// nestGrid
//
nestGrid.AllowUserToAddRows = false;
nestGrid.AllowUserToDeleteRows = false;
nestGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nestNameColumn, groupColumn, statusColumn, nestCustomerColumn, modifiedColumn });
nestGrid.Dock = System.Windows.Forms.DockStyle.Fill;
nestGrid.MultiSelect = false;
nestGrid.Name = "nestGrid";
nestGrid.ReadOnly = true;
nestGrid.RowHeadersVisible = false;
nestGrid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
nestGrid.StandardTab = true;
nestGrid.TabIndex = 0;
//
// nestNameColumn
//
nestNameColumn.HeaderText = "Nest";
nestNameColumn.Name = "nestNameColumn";
nestNameColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
nestNameColumn.Width = 160;
//
// groupColumn
//
groupColumn.HeaderText = "Material group";
groupColumn.Name = "groupColumn";
groupColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
groupColumn.Width = 260;
//
// statusColumn
//
statusColumn.HeaderText = "Status";
statusColumn.Name = "statusColumn";
statusColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
statusColumn.Width = 110;
//
// nestCustomerColumn
//
nestCustomerColumn.HeaderText = "Customer";
nestCustomerColumn.Name = "nestCustomerColumn";
nestCustomerColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
nestCustomerColumn.Width = 140;
//
// modifiedColumn
//
modifiedColumn.HeaderText = "Modified";
modifiedColumn.Name = "modifiedColumn";
modifiedColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
modifiedColumn.Width = 130;
//
// bottomTable
//
bottomTable.AutoSize = true;
bottomTable.ColumnCount = 4;
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.Controls.Add(previousButton, 0, 0);
bottomTable.Controls.Add(nextButton, 1, 0);
bottomTable.Controls.Add(summaryLabel, 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";
bottomTable.RowCount = 1;
bottomTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
bottomTable.TabIndex = 2;
//
// previousButton
//
previousButton.AutoSize = true;
previousButton.Enabled = false;
previousButton.Name = "previousButton";
previousButton.TabIndex = 0;
previousButton.Text = "< Newer";
previousButton.UseVisualStyleBackColor = true;
previousButton.Click += PreviousButton_Click;
//
// nextButton
//
nextButton.AutoSize = true;
nextButton.Enabled = false;
nextButton.Name = "nextButton";
nextButton.TabIndex = 1;
nextButton.Text = "Older >";
nextButton.UseVisualStyleBackColor = true;
nextButton.Click += NextButton_Click;
//
// summaryLabel
//
summaryLabel.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
summaryLabel.AutoEllipsis = true;
summaryLabel.Name = "summaryLabel";
summaryLabel.TabIndex = 2;
//
// closeButton
//
closeButton.AutoSize = true;
closeButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
closeButton.Name = "closeButton";
closeButton.TabIndex = 3;
closeButton.Text = "Close";
closeButton.UseVisualStyleBackColor = true;
//
// SetReleasesForm
//
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
CancelButton = closeButton;
ClientSize = new System.Drawing.Size(824, 541);
Controls.Add(layoutTable);
Font = new System.Drawing.Font("Segoe UI", 9.75F);
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(600, 400);
Name = "SetReleasesForm";
ShowIcon = false;
ShowInTaskbar = false;
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Set Releases";
Shown += SetReleasesForm_Shown;
layoutTable.ResumeLayout(false);
layoutTable.PerformLayout();
((System.ComponentModel.ISupportInitialize)releaseGrid).EndInit();
nestsGroup.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)nestGrid).EndInit();
bottomTable.ResumeLayout(false);
bottomTable.PerformLayout();
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TableLayoutPanel layoutTable;
private System.Windows.Forms.DataGridView releaseGrid;
private System.Windows.Forms.DataGridViewTextBoxColumn revisionColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn releasedColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn releasedByColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn multiplierColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn nestsColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn progressColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn releaseNotesColumn;
private System.Windows.Forms.GroupBox nestsGroup;
private System.Windows.Forms.DataGridView nestGrid;
private System.Windows.Forms.DataGridViewTextBoxColumn nestNameColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn groupColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn statusColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn nestCustomerColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn modifiedColumn;
private System.Windows.Forms.TableLayoutPanel bottomTable;
private System.Windows.Forms.Button previousButton;
private System.Windows.Forms.Button nextButton;
private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.Button closeButton;
}
}
+132
View File
@@ -0,0 +1,132 @@
using System;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Data;
namespace OpenNest.Forms
{
/// <summary>
/// Drawing Sets &gt; Releases: one set's releases, newest first, a bounded page at a time,
/// each with its progress ("2 of 3 cut"), and the nests of the selected release with their
/// current status, or Deleted. The server joins every nest's current metadata on each read.
/// </summary>
public partial class SetReleasesForm : Form
{
private const int PageSize = 100;
private readonly IDrawingSetStore sets;
private readonly DrawingSetRecord set;
private SetReleasePage page;
private int offset;
private int pageRequest;
public SetReleasesForm(IDrawingSetStore sets, DrawingSetRecord set)
{
InitializeComponent();
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
this.set = set ?? throw new ArgumentNullException(nameof(set));
GridStyle.Apply(releaseGrid);
GridStyle.Apply(nestGrid);
Text = $"Releases of '{set.Name}'";
}
private async void SetReleasesForm_Shown(object sender, EventArgs e) => await LoadPageAsync(0);
private async void PreviousButton_Click(object sender, EventArgs e) =>
await LoadPageAsync(System.Math.Max(0, offset - PageSize));
private async void NextButton_Click(object sender, EventArgs e) => await LoadPageAsync(offset + PageSize);
private void ReleaseGrid_CurrentCellChanged(object sender, EventArgs e) =>
ShowNests(releaseGrid.CurrentRow?.Tag as SetReleaseRecord);
/// <summary>Reads one page of releases; a later request discards an earlier one's result.</summary>
internal async Task LoadPageAsync(int requestedOffset)
{
var request = ++pageRequest;
previousButton.Enabled = nextButton.Enabled = false;
summaryLabel.Text = "Loading...";
try
{
var loaded = await sets.GetReleasesAsync(set.Id, requestedOffset, PageSize);
if (IsDisposed || request != pageRequest)
return;
if (loaded == null)
{
summaryLabel.Text = "This set no longer exists on the server.";
return;
}
page = loaded;
offset = requestedOffset;
releaseGrid.Rows.Clear();
nestGrid.Rows.Clear();
foreach (var release in loaded.Items)
{
// Tagged before it is added: adding the first row selects it, which shows its nests.
var row = new DataGridViewRow { Tag = release };
row.CreateCells(releaseGrid,
release.ReleasedAt == default ? "" : release.ReleasedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture),
release.ReleasedBy,
release.SetRevision,
$"×{release.Multiplier}",
release.Nests.Count,
SetReleaseText.Progress(release),
release.Notes);
releaseGrid.Rows.Add(row);
}
// The grid only sometimes makes the first row current by itself (it depends on focus).
if (releaseGrid.CurrentCell == null && releaseGrid.Rows.Count > 0)
releaseGrid.CurrentCell = releaseGrid.Rows[0].Cells[0];
summaryLabel.Text = loaded.Total == 0
? "This set has not been released yet."
: $"Releases {offset + 1:N0}-{offset + loaded.Items.Count:N0} of {loaded.Total:N0}";
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
if (!IsDisposed && request == pageRequest)
summaryLabel.Text = $"Could not read the releases: {ex.Message}";
}
finally
{
if (!IsDisposed && request == pageRequest)
{
previousButton.Enabled = offset > 0;
nextButton.Enabled = page != null && offset + page.Items.Count < page.Total;
}
}
}
private void ShowNests(SetReleaseRecord release)
{
nestGrid.Rows.Clear();
if (release == null)
{
nestsGroup.Text = "Nests of the selected release";
return;
}
foreach (var nest in release.Nests)
{
var record = nest.Nest;
nestGrid.Rows.Add(
record?.Name ?? nest.NestId.ToString(),
SetReleaseText.Group(nest),
SetReleaseText.NestState(nest),
record?.Customer ?? "",
record == null || record.DateModified == default
? ""
: record.DateModified.ToString("g", CultureInfo.CurrentCulture));
}
nestsGroup.Text = $"Nests of the release on {release.ReleasedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}: "
+ SetReleaseText.Progress(release);
}
}
}
+32 -2
View File
@@ -360,6 +360,37 @@ whose current revision contains it) or **Add to Set...**: choose an active set
or **New Set**, and the set editor opens with the drawing added at the entered
quantity for review and Save.
**Release...** (the Sets tab, when the dialog was opened from a nest) reads the
selected active set with its members and opens the release dialog. It lists one
row per material group (`OpenNest.Data.SetReleasePlan`): material, grade,
thickness, units, cut type, drawings, parts and status. **Kits (multiplier)**
multiplies every quantity (1 to 1000); **Include archived drawings** puts
archived 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 multiplier or the archived toggle. **Release**
confirms, then runs `OpenNest.Data.SetReleaseSession`: every ticked group's nest
is made as File > New would (the server's next nest name, the saved plate
defaults and clamps), in the group's units, with the saved plate size and
spacings converted (25.4 mm per inch) when those differ; the nest takes the
group's material, grade and thickness, the set's customer and the **Draft**
status. Each created nest opens in its own tab, bound to its server record, and
the release (with the release notes) is recorded. The grid then shows every
group's outcome: Created with the nest name, Refused or Failed with the reason,
or Not started. Cancel stops between uploads, never during one. When every nest
was created but recording failed, the button becomes **Record Release** and
posts the same release again. A dialog runs one release.
**Releases...** lists the selected set's releases, newest first: when, by whom,
the set revision, the kit multiplier, the number of nests, progress such as
"2 of 3 cut" (deleted nests are not counted) and the notes. The selected
release's nests show their material group and current status, or Deleted.
**Nest > Edit** (Nest Info) shows, beside its buttons, the release a server nest
came from ("Released from set 'Kit A' revision 3 ×2 on 10/20/2026 by ..."). It is
read in the background and left blank when the nest came from none or the
server cannot be reached.
## Drawing set endpoints
A **drawing set** (schema v7) is a named, versioned list of library drawings
@@ -367,8 +398,7 @@ with a quantity each: a kit, an assembly or a recurring order. Members name
drawings by id and always mean each drawing's **current** revision; there is no
per-member revision pin. Sets travel as JSON (`application/json`, at most 1 MiB).
The desktop browses and edits them on the Drawing Library dialog's Sets tab
([Drawing sets (desktop)](#drawing-sets-desktop)); releasing a set is not in the
desktop yet.
([Drawing sets (desktop)](#drawing-sets-desktop)), where sets are also released.
`OpenNest.Data.DrawingSetRecord` fields: `id` (client-allocated before the
first save), `revision`, `name`, `customer`, `notes`, `archived`, `savedBy`,