mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
feat(ui): rename nests from Nest Info in Database mode
The nest name box was always read-only, so a nest could only be renamed by Save As, which leaves the old copy behind. In Database mode Nest Info now edits the name (uploaded with the next Save), as does the saved-nest browser's Nest Information (saved immediately). File mode keeps it read-only because the file name is the nest name. Renaming to a name another active nest already has asks first, and declining reopens the dialog with the edits kept; a duplicate accepted there is not asked about again by the nest's first Save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7df3f5e23
commit
e82f26b976
13 files changed
+241
-21
No files matched your search
@@ -4,9 +4,9 @@ using System.Text.Json;
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Duplicate-name check for a nest's first upload. Names may already be on posted CNC
|
||||
/// programs and labels, so a nest is never renamed silently: callers offer the
|
||||
/// replacement only when another active nest already has the exact same name.
|
||||
/// Duplicate-name checks for a nest's first upload and for renames. Names may already be
|
||||
/// on posted CNC programs and labels, so a nest is never renamed silently: callers offer
|
||||
/// a replacement, or warn, only when another active nest has the exact same name.
|
||||
/// </summary>
|
||||
public static class NestNameConflicts
|
||||
{
|
||||
@@ -24,21 +24,42 @@ public static class NestNameConflicts
|
||||
|
||||
try
|
||||
{
|
||||
if (!await IsNameTakenAsync(repository, name, cancellationToken))
|
||||
if (!await IsNameTakenAsync(repository, name, cancellationToken: cancellationToken))
|
||||
return null;
|
||||
var number = await repository.AllocateNestNumberAsync(year, cancellationToken);
|
||||
return new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) };
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException
|
||||
or NotSupportedException or ArgumentException or JsonException or InvalidOperationException)
|
||||
catch (Exception ex) when (IsCheckFailure(ex))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when an active (not deleted) server nest is named exactly <paramref name="name"/>.</summary>
|
||||
/// <summary>
|
||||
/// <see cref="IsNameTakenAsync"/>, or null when the server could not answer
|
||||
/// (offline, too slow, or an older server), so the caller can go ahead without warning.
|
||||
/// </summary>
|
||||
public static async Task<bool?> TryIsNameTakenAsync(
|
||||
INestRepository repository, string name, Guid excludeId = default,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await IsNameTakenAsync(repository, name, excludeId, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (IsCheckFailure(ex))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when an active (not deleted) server nest other than <paramref name="excludeId"/>
|
||||
/// is named exactly <paramref name="name"/>.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsNameTakenAsync(
|
||||
INestRepository repository, string name, CancellationToken cancellationToken = default)
|
||||
INestRepository repository, string name, Guid excludeId = default,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
// The search is a case-insensitive substring match; page through it for the exact name.
|
||||
@@ -52,11 +73,16 @@ public static class NestNameConflicts
|
||||
Offset = offset,
|
||||
Limit = NestQuery.MaxLimit,
|
||||
}, cancellationToken);
|
||||
if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal)))
|
||||
if (page.Items.Any(r => !r.Deleted && r.Id != excludeId
|
||||
&& string.Equals(r.Name, name, StringComparison.Ordinal)))
|
||||
return true;
|
||||
offset += page.Items.Count;
|
||||
if (page.Items.Count == 0 || offset >= page.Total)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCheckFailure(Exception ex) =>
|
||||
ex is HttpRequestException or IOException or OperationCanceledException
|
||||
or NotSupportedException or ArgumentException or JsonException or InvalidOperationException;
|
||||
}
|
||||
@@ -73,6 +73,23 @@ public sealed class NestSequenceTests
|
||||
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_WarnsOnlyAboutOtherActiveNests()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
Guid own;
|
||||
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
||||
{
|
||||
own = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Bracket job" }, [1]).Id;
|
||||
database.Insert(Guid.NewGuid(), new NestRecord { Name = "Gusset job" }, [2]);
|
||||
}
|
||||
using var client = factory.CreateClient();
|
||||
var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
|
||||
|
||||
Assert.False(await NestNameConflicts.TryIsNameTakenAsync(repository, "Bracket job", own));
|
||||
Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Gusset job", own));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
|
||||
{
|
||||
|
||||
@@ -45,6 +45,26 @@ public class NestNameConflictsTests
|
||||
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_IgnoresTheNestsOwnRecord()
|
||||
{
|
||||
var repository = new NamedRepository("Job");
|
||||
var own = repository.Records[0].Id;
|
||||
|
||||
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job", own));
|
||||
Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job", Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryIsNameTaken_IsUnknownWhenTheServerCannotAnswer()
|
||||
{
|
||||
var repository = new NamedRepository("Job") { QueryFailure = new HttpRequestException("offline") };
|
||||
|
||||
Assert.Null(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job"));
|
||||
repository.QueryFailure = null;
|
||||
Assert.True(await NestNameConflicts.TryIsNameTakenAsync(repository, "Job"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateBeyondTheFirstPage_IsFound()
|
||||
{
|
||||
|
||||
@@ -107,7 +107,8 @@ public class EditNestInfoFormTests
|
||||
accept.Start();
|
||||
// This is the live Nest Info entry point; it owns LoadNestInfo,
|
||||
// ShowDialog and SaveNestInfo after the actual OK button accepts.
|
||||
editor.ShowNestInfoEditor();
|
||||
// An unchanged name never checks the server, so this completes without awaiting.
|
||||
editor.ShowNestInfoEditorAsync().GetAwaiter().GetResult();
|
||||
accept.Stop();
|
||||
if (failure != null)
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
@@ -122,6 +123,45 @@ public class EditNestInfoFormTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_IsReadOnlyUnlessRenameIsAllowed()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
var nest = AcceptanceNest();
|
||||
using var form = new EditNestInfoForm();
|
||||
form.LoadNestInfo(nest);
|
||||
var nameBox = Assert.IsType<TextBox>(form.Controls.Find("nameBox", true).Single());
|
||||
Assert.True(nameBox.ReadOnly);
|
||||
|
||||
form.AllowRename = true;
|
||||
Assert.False(nameBox.ReadOnly);
|
||||
nameBox.Text = " Renamed job ";
|
||||
form.SaveNestInfo(nest);
|
||||
|
||||
Assert.Equal("Renamed job", nest.Name);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankName_DisablesOk()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var form = new EditNestInfoForm { AllowRename = true };
|
||||
form.LoadNestInfo(AcceptanceNest());
|
||||
form.EnableCheck(); // The size box re-checks on a debounce timer.
|
||||
var nameBox = Assert.IsType<TextBox>(form.Controls.Find("nameBox", true).Single());
|
||||
var ok = Assert.IsType<Button>(form.Controls.Find("applyButton", true).Single());
|
||||
Assert.True(ok.Enabled);
|
||||
|
||||
nameBox.Text = " ";
|
||||
Assert.False(ok.Enabled);
|
||||
nameBox.Text = "Job";
|
||||
Assert.True(ok.Enabled);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveWithUnchangedMaterial_KeepsGradeAndDensity()
|
||||
{
|
||||
|
||||
@@ -187,6 +187,9 @@ public class SavedNestsFormTests
|
||||
dialog.Notes = "Updated notes";
|
||||
dialog.Status = NestStatus.HasBeenCut;
|
||||
dialog.MadeBy = "Operator";
|
||||
var nameBox = Assert.IsType<TextBox>(dialog.Controls.Find("nameBox", true).Single());
|
||||
Assert.False(nameBox.ReadOnly);
|
||||
nameBox.Text = "Renamed job";
|
||||
return accept ? DialogResult.OK : DialogResult.Cancel;
|
||||
};
|
||||
var button = Field<Button>(form, "infoButton");
|
||||
@@ -206,6 +209,7 @@ public class SavedNestsFormTests
|
||||
Assert.Equal(4, record.Revision);
|
||||
Assert.Equal("Updated customer", record.Customer);
|
||||
Assert.Equal("Updated notes", record.Comments);
|
||||
Assert.Equal("Renamed job", record.Name);
|
||||
using var stream = new MemoryStream(repository.Files[record.Id]);
|
||||
var nest = new NestReader(stream).Read();
|
||||
Assert.Equal("Updated customer", nest.Customer);
|
||||
|
||||
@@ -23,6 +23,13 @@ namespace OpenNest
|
||||
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
|
||||
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
|
||||
public bool IsFirstDatabaseSave(string serverUrl) => remoteSession.IsFirstSave(serverUrl);
|
||||
|
||||
/// <summary>
|
||||
/// A duplicate name the user already accepted in Nest Info, so the first Save
|
||||
/// does not ask about it again.
|
||||
/// </summary>
|
||||
public string AcceptedDuplicateName { get; set; }
|
||||
|
||||
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
|
||||
|
||||
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
|
||||
|
||||
@@ -114,7 +114,8 @@ namespace OpenNest.Forms.Commands
|
||||
}
|
||||
if (copyName != null)
|
||||
form.Nest.Name = copyName;
|
||||
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl))
|
||||
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl)
|
||||
&& form.Nest.Name != form.Document.AcceptedDuplicateName)
|
||||
{
|
||||
var chosen = await ChooseFirstSaveNameAsync(repository, form.Nest.Name);
|
||||
if (chosen is null)
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Actions;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
@@ -751,19 +753,44 @@ namespace OpenNest.Forms
|
||||
PlateView.ZoomToFit();
|
||||
}
|
||||
|
||||
public void ShowNestInfoEditor()
|
||||
public async Task ShowNestInfoEditorAsync()
|
||||
{
|
||||
var form = new EditNestInfoForm();
|
||||
var form = new EditNestInfoForm { AllowRename = NestStorage.Settings.IsDatabaseMode };
|
||||
form.LoadNestInfo(Nest);
|
||||
|
||||
if (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
while (form.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
// A declined duplicate name reopens the editor with the user's edits intact.
|
||||
if (form.NestName != Nest.Name && !await ConfirmRenameAsync(form.NestName))
|
||||
continue;
|
||||
|
||||
PlateManager.ChangePlateDefaults(() => form.SaveNestInfo(Nest));
|
||||
Text = Nest.Name;
|
||||
drawingListBox1.Units = Nest.Units;
|
||||
drawingListBox1.Invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Renames go up with the next Save; warn now about a duplicate name.</summary>
|
||||
private async Task<bool> ConfirmRenameAsync(string name)
|
||||
{
|
||||
INestRepository repository;
|
||||
try
|
||||
{
|
||||
repository = NestStorage.CreateRepository();
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException)
|
||||
{
|
||||
return true; // No usable server address; Save reports that itself.
|
||||
}
|
||||
|
||||
var duplicateAccepted = await NestRenameConfirmation.ConfirmAsync(this, repository, name, Document.RemoteId);
|
||||
if (duplicateAccepted == true)
|
||||
Document.AcceptedDuplicateName = name; // So the first Save does not ask again.
|
||||
return duplicateAccepted != false;
|
||||
}
|
||||
|
||||
public void ResizePlateToFitParts()
|
||||
{
|
||||
var options = new PlateSizeOptions
|
||||
|
||||
@@ -31,15 +31,25 @@ namespace OpenNest.Forms
|
||||
Interval = SystemInformation.KeyboardDelay + 1,
|
||||
};
|
||||
timer.Elapsed += (sender, e) => EnableCheck();
|
||||
nameBox.TextChanged += (sender, e) => EnableCheck();
|
||||
EnableCheck();
|
||||
}
|
||||
|
||||
public string NestName
|
||||
{
|
||||
get { return nameBox.Text; }
|
||||
get { return nameBox.Text.Trim(); }
|
||||
private set { nameBox.Text = value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Database mode only: in File mode the name is the file name, set by Save As.
|
||||
/// </summary>
|
||||
public bool AllowRename
|
||||
{
|
||||
get { return !nameBox.ReadOnly; }
|
||||
set { nameBox.ReadOnly = !value; }
|
||||
}
|
||||
|
||||
public string Customer
|
||||
{
|
||||
get { return customerBox.Text; }
|
||||
@@ -187,6 +197,12 @@ namespace OpenNest.Forms
|
||||
{
|
||||
Size size;
|
||||
|
||||
if (NestName.Length == 0)
|
||||
{
|
||||
applyButton.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OpenNest.Geometry.Size.TryParse(sizeBox.Text, out size))
|
||||
{
|
||||
applyButton.Enabled = false;
|
||||
|
||||
@@ -45,7 +45,11 @@ namespace OpenNest.Forms
|
||||
mnuNestDrawingLibrary.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
|
||||
}
|
||||
|
||||
private void EditNest_Click(object sender, EventArgs e) => WithActive(f => f.ShowNestInfoEditor());
|
||||
private async void EditNest_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm != null)
|
||||
await activeForm.ShowNestInfoEditorAsync();
|
||||
}
|
||||
|
||||
private void Clamps_Click(object sender, EventArgs e)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// Nest Info renames in Database mode: warns before taking a name another active server
|
||||
/// nest already has. A check that fails or takes over 3 seconds lets the rename through.
|
||||
/// </summary>
|
||||
internal static class NestRenameConfirmation
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Null when no other nest is known to have <paramref name="name"/>; otherwise whether
|
||||
/// the user chose to use the duplicate anyway.
|
||||
/// </summary>
|
||||
public static async Task<bool?> ConfirmAsync(Control owner, INestRepository repository, string name, Guid ownId)
|
||||
{
|
||||
bool? taken;
|
||||
owner.UseWaitCursor = true;
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(Timeout);
|
||||
taken = await NestNameConflicts.TryIsNameTakenAsync(repository, name, ownId, timeout.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
owner.UseWaitCursor = false;
|
||||
}
|
||||
if (taken != true)
|
||||
return null;
|
||||
|
||||
return MessageBox.Show(owner,
|
||||
$"A nest named \"{name}\" is already on the server. Use this name anyway?",
|
||||
"Nest Info", MessageBoxButtons.YesNo, MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button2) == DialogResult.Yes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -821,10 +821,17 @@ public sealed class SavedNestsForm : Form
|
||||
return loaded;
|
||||
});
|
||||
if (IsDisposed) return;
|
||||
using var dialog = new EditNestInfoForm();
|
||||
using var dialog = new EditNestInfoForm { AllowRename = true };
|
||||
dialog.LoadNestInfo(nest);
|
||||
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
|
||||
return;
|
||||
while (true)
|
||||
{
|
||||
if (ShowNestInfoDialog(dialog) != DialogResult.OK)
|
||||
return;
|
||||
// A declined duplicate name reopens the dialog with the user's edits intact.
|
||||
if (dialog.NestName == nest.Name
|
||||
|| await NestRenameConfirmation.ConfirmAsync(this, repository, dialog.NestName, id) != false)
|
||||
break;
|
||||
}
|
||||
// Same default propagation as Nest > Nest Info in the open editor.
|
||||
using var manager = new PlateManager(nest);
|
||||
manager.ChangePlateDefaults(() => dialog.SaveNestInfo(nest));
|
||||
|
||||
+10
-2
@@ -29,8 +29,8 @@ The desktop app can save nests two ways:
|
||||
without binding an editable document, and **Download selected version...** saves
|
||||
its exact archived bytes.
|
||||
Select a nest and click **Nest Information...** above the list (or use the
|
||||
row's right-click menu) to edit the same information as **Nest > Nest Info**
|
||||
without opening the nest in the main editor. **OK** saves immediately to the
|
||||
row's right-click menu) to edit the same information as **Nest > Nest Info**,
|
||||
including the name, without opening the nest in the main editor. **OK** saves immediately to the
|
||||
same database ID as a new version, then refreshes the list, preview and history;
|
||||
**Cancel** makes no change. The dialog starts with the latest saved metadata.
|
||||
Plate defaults follow the same rules as the open editor: populated sheets are
|
||||
@@ -142,6 +142,14 @@ allocate the year's next name and show it in an editable dialog: OK uploads
|
||||
under the chosen name, Cancel saves nothing. If the check or the allocation
|
||||
fails or takes longer than 3 seconds, the nest uploads under its current name.
|
||||
|
||||
In Database mode the name can be edited in **Nest > Nest Info** (it uploads with
|
||||
the next Save) and in the saved-nest browser's **Nest Information...** (saved
|
||||
immediately). In File mode it stays read-only, since the file name is the nest
|
||||
name. If another active nest already has the exact new name, a warning asks
|
||||
before using it; **No** reopens the dialog with the edits kept. A name accepted
|
||||
this way is not asked about again by the nest's first Save. A rename that the
|
||||
server cannot check within 3 seconds goes ahead without a warning.
|
||||
|
||||
There is no authentication; this is a LAN-only service. Do not expose it
|
||||
outside the shop network without adding one.
|
||||
|
||||
|
||||
Reference in new issue
Block a user