mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-04 09:02:13 -04:00
feat(desktop): page and filter database nests on the server
The Database-mode Open dialog no longer downloads every record. It browses through NestBrowseSession, which sends one bounded query per change: the filter box (300 ms debounce) searches on the server and returns to the first page, column headers sort every match on the server (a second click reverses), and Previous/Next page by 100 with a range and total in the status line. A response superseded by a newer request is cancelled and discarded, refresh steps back when the current page was emptied, and failures clear the rows and show the error without a modal box per keystroke.
This commit is contained in:
1 parent
646d4c3975
commit
96267e760e
4 files changed
+557
-83
No files matched your search
@@ -0,0 +1,170 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for the Database-mode Open dialog: the requested search, sort and page,
|
||||
/// and the last page applied. Every change issues one bounded server query. A response
|
||||
/// that a later request superseded is discarded (and the earlier request is cancelled),
|
||||
/// so an older result can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class NestBrowseSession : IDisposable
|
||||
{
|
||||
private readonly INestRepository _repository;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}.");
|
||||
|
||||
_repository = repository;
|
||||
PageSize = pageSize;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public NestPage? Page { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
return Search.Length == 0 ? "No nests on the server." : "No nests match the filter.";
|
||||
if (page.Items.Count == 0)
|
||||
return $"No nests on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(NestSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Superseded sources are cancelled but not disposed: their request may still observe the token.
|
||||
_pending?.Cancel();
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
|
||||
try
|
||||
{
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// A deletion or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_repository.QueryAsync(
|
||||
new NestQuery
|
||||
{
|
||||
Search = Search,
|
||||
Sort = Sort,
|
||||
Descending = Descending,
|
||||
Offset = Offset,
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Cancels any request in flight; the repository is not owned and stays open.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
_pending?.Cancel();
|
||||
_pending?.Dispose();
|
||||
_pending = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
public class NestBrowseSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Refresh_RequestsTheFirstBoundedPageNewestSavedFirst()
|
||||
{
|
||||
var repository = new PagedRepository(25);
|
||||
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||
|
||||
Assert.True(await session.RefreshAsync());
|
||||
|
||||
var query = Assert.Single(repository.Queries);
|
||||
Assert.Equal("", query.Search);
|
||||
Assert.Equal(NestSortField.SavedAt, query.Sort);
|
||||
Assert.True(query.Descending);
|
||||
Assert.Equal(0, query.Offset);
|
||||
Assert.Equal(10, query.Limit);
|
||||
Assert.Equal(10, session.Page!.Items.Count);
|
||||
Assert.Equal("Showing 1-10 of 25 nests", session.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextAndPrevious_StayWithinTheMatches()
|
||||
{
|
||||
var repository = new PagedRepository(25);
|
||||
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||
await session.RefreshAsync();
|
||||
Assert.False(session.CanGoPrevious);
|
||||
Assert.True(session.CanGoNext);
|
||||
|
||||
Assert.True(await session.NextPageAsync());
|
||||
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
|
||||
Assert.True(await session.NextPageAsync());
|
||||
Assert.Equal("Showing 21-25 of 25 nests", session.Summary);
|
||||
Assert.False(session.CanGoNext);
|
||||
Assert.True(session.CanGoPrevious);
|
||||
|
||||
var sent = repository.Queries.Count;
|
||||
Assert.False(await session.NextPageAsync());
|
||||
Assert.Equal(sent, repository.Queries.Count);
|
||||
|
||||
Assert.True(await session.PreviousPageAsync());
|
||||
Assert.Equal(10, repository.Queries[^1].Offset);
|
||||
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetSearch_TrimsTextAndReturnsToTheFirstPage()
|
||||
{
|
||||
var repository = new PagedRepository(25);
|
||||
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||
await session.RefreshAsync();
|
||||
await session.NextPageAsync();
|
||||
|
||||
await session.SetSearchAsync(" beta works ");
|
||||
|
||||
var query = repository.Queries[^1];
|
||||
Assert.Equal("beta works", query.Search);
|
||||
Assert.Equal(0, query.Offset);
|
||||
Assert.Equal("beta works", session.Search);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SortBy_NewColumnAscends_SameColumnReverses_AndResetsTheOffset()
|
||||
{
|
||||
var repository = new PagedRepository(25);
|
||||
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||
await session.RefreshAsync();
|
||||
await session.NextPageAsync();
|
||||
|
||||
await session.SortByAsync(NestSortField.Customer);
|
||||
Assert.Equal((NestSortField.Customer, false, 0), Last(repository));
|
||||
|
||||
await session.SortByAsync(NestSortField.Customer);
|
||||
Assert.Equal((NestSortField.Customer, true, 0), Last(repository));
|
||||
|
||||
await session.SortByAsync(NestSortField.Thickness);
|
||||
Assert.Equal((NestSortField.Thickness, false, 0), Last(repository));
|
||||
|
||||
await session.SortByAsync(NestSortField.SavedAt);
|
||||
await session.SortByAsync(NestSortField.SavedAt);
|
||||
Assert.Equal((NestSortField.SavedAt, true, 0), Last(repository));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult()
|
||||
{
|
||||
var repository = new ControlledRepository();
|
||||
using var session = new NestBrowseSession(repository);
|
||||
var older = session.SetSearchAsync("a");
|
||||
var newer = session.SetSearchAsync("ab");
|
||||
var newerPage = Page("ab result");
|
||||
|
||||
repository.Requests[1].Reply.SetResult(newerPage);
|
||||
Assert.True(await newer);
|
||||
repository.Requests[0].Reply.SetResult(Page("a result"));
|
||||
Assert.False(await older);
|
||||
|
||||
Assert.Same(newerPage, session.Page);
|
||||
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
|
||||
Assert.False(repository.Requests[1].Token.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates()
|
||||
{
|
||||
var repository = new ControlledRepository();
|
||||
using var session = new NestBrowseSession(repository);
|
||||
var older = session.SetSearchAsync("a");
|
||||
var newer = session.SetSearchAsync("ab");
|
||||
|
||||
repository.Requests[0].Reply.SetException(new IOException("superseded failure"));
|
||||
Assert.False(await older);
|
||||
repository.Requests[1].Reply.SetResult(Page("ab result"));
|
||||
Assert.True(await newer);
|
||||
Assert.NotNull(session.Page);
|
||||
|
||||
var failing = session.RefreshAsync();
|
||||
repository.Requests[2].Reply.SetException(new IOException("server offline"));
|
||||
var error = await Assert.ThrowsAsync<IOException>(() => failing);
|
||||
|
||||
Assert.Equal("server offline", error.Message);
|
||||
Assert.Null(session.Page);
|
||||
Assert.Equal("", session.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage()
|
||||
{
|
||||
var repository = new PagedRepository(21);
|
||||
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||
await session.RefreshAsync();
|
||||
await session.NextPageAsync();
|
||||
await session.NextPageAsync();
|
||||
Assert.Equal("Showing 21-21 of 21 nests", session.Summary);
|
||||
|
||||
repository.RemoveLast();
|
||||
Assert.True(await session.RefreshAsync());
|
||||
|
||||
Assert.Equal(new[] { 20, 10 }, repository.Queries.TakeLast(2).Select(q => q.Offset));
|
||||
Assert.Equal(10, session.Offset);
|
||||
Assert.Equal("Showing 11-20 of 20 nests", session.Summary);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "No nests on the server.")]
|
||||
[InlineData("no such text", "No nests match the filter.")]
|
||||
public async Task Summary_DistinguishesAnEmptyServerFromNoMatches(string search, string expected)
|
||||
{
|
||||
using var session = new NestBrowseSession(new PagedRepository(0));
|
||||
|
||||
await session.SetSearchAsync(search);
|
||||
|
||||
Assert.Equal(expected, session.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse()
|
||||
{
|
||||
var repository = new ControlledRepository();
|
||||
var session = new NestBrowseSession(repository);
|
||||
var pending = session.SetSearchAsync("a");
|
||||
|
||||
session.Dispose();
|
||||
repository.Requests[0].Reply.SetResult(Page("late"));
|
||||
|
||||
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
|
||||
Assert.False(await pending);
|
||||
Assert.Null(session.Page);
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.RefreshAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(NestQuery.MaxLimit + 1)]
|
||||
public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestBrowseSession(new PagedRepository(0), pageSize));
|
||||
}
|
||||
|
||||
private static (NestSortField, bool, int) Last(PagedRepository repository)
|
||||
{
|
||||
var query = repository.Queries[^1];
|
||||
return (query.Sort, query.Descending, query.Offset);
|
||||
}
|
||||
|
||||
private static NestPage Page(string name) =>
|
||||
new() { Items = new[] { new NestRecord { Id = Guid.NewGuid(), Name = name } }, Total = 1, Limit = 100 };
|
||||
|
||||
/// <summary>Serves pages of an in-memory set and records each query.</summary>
|
||||
private sealed class PagedRepository : RepositoryBase
|
||||
{
|
||||
private readonly List<NestRecord> _records;
|
||||
|
||||
public PagedRepository(int count) =>
|
||||
_records = Enumerable.Range(0, count).Select(i => new NestRecord { Id = Guid.NewGuid(), Name = $"Nest {i}" }).ToList();
|
||||
|
||||
public List<NestQuery> Queries { get; } = new();
|
||||
|
||||
public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
|
||||
|
||||
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(query);
|
||||
var matches = query.NormalizedSearch.Length == 0 ? _records : new List<NestRecord>();
|
||||
return Task.FromResult(new NestPage
|
||||
{
|
||||
Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(),
|
||||
Total = matches.Count,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Leaves every query pending until the test completes it.</summary>
|
||||
private sealed class ControlledRepository : RepositoryBase
|
||||
{
|
||||
public List<(NestQuery Query, TaskCompletionSource<NestPage> Reply, CancellationToken Token)> Requests { get; } = new();
|
||||
|
||||
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reply = new TaskCompletionSource<NestPage>();
|
||||
Requests.Add((query, reply, cancellationToken));
|
||||
return reply.Task;
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class RepositoryBase : INestRepository
|
||||
{
|
||||
public abstract Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, 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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
@@ -9,18 +7,26 @@ using OpenNest.Data;
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// File > Open (Database mode): lists nests from the shared nest server with
|
||||
/// filterable/sortable metadata columns, and lets the operator open or delete one.
|
||||
/// File > Open (Database mode): browses nests on the shared nest server one bounded
|
||||
/// page at a time. Filtering, sorting and paging run on the server through
|
||||
/// <see cref="NestBrowseSession"/>; this form only renders its state.
|
||||
/// </summary>
|
||||
public sealed class SavedNestsForm : Form
|
||||
{
|
||||
private const string IdColumn = "Id";
|
||||
private const string LoadingText = "Loading...";
|
||||
|
||||
private readonly INestRepository repository;
|
||||
private readonly NestBrowseSession session;
|
||||
private readonly DataGridView grid;
|
||||
private readonly TextBox searchBox;
|
||||
private readonly Timer searchTimer;
|
||||
private readonly Button openButton;
|
||||
private readonly Button deleteButton;
|
||||
private readonly Button refreshButton;
|
||||
private NestRecord[] records = Array.Empty<NestRecord>();
|
||||
private readonly Button previousButton;
|
||||
private readonly Button nextButton;
|
||||
private readonly ToolStripStatusLabel statusLabel;
|
||||
private int pendingRequests;
|
||||
|
||||
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
|
||||
public Guid SelectedId { get; private set; }
|
||||
@@ -28,6 +34,7 @@ public sealed class SavedNestsForm : Form
|
||||
public SavedNestsForm(INestRepository repository)
|
||||
{
|
||||
this.repository = repository;
|
||||
session = new NestBrowseSession(repository);
|
||||
|
||||
Text = "Open Nest — Database";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
@@ -36,15 +43,37 @@ public sealed class SavedNestsForm : Form
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
var topPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(6) };
|
||||
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Location = new Point(6, 10) };
|
||||
searchBox = new TextBox { Location = new Point(50, 6), Width = 300 };
|
||||
searchBox.TextChanged += (_, _) => ApplyFilter();
|
||||
refreshButton = new Button { Text = "Refresh", AutoSize = true, Location = new Point(360, 5) };
|
||||
refreshButton.Click += async (_, _) => await LoadAsync();
|
||||
var topPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 34,
|
||||
Padding = new Padding(3),
|
||||
WrapContents = false,
|
||||
};
|
||||
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Margin = new Padding(3, 7, 0, 0) };
|
||||
searchBox = new TextBox { Width = 300, MaxLength = NestQuery.MaxSearchLength };
|
||||
searchTimer = new Timer { Interval = 300 };
|
||||
searchTimer.Tick += async (_, _) =>
|
||||
{
|
||||
searchTimer.Stop();
|
||||
await RunAsync(() => session.SetSearchAsync(searchBox.Text));
|
||||
};
|
||||
searchBox.TextChanged += (_, _) =>
|
||||
{
|
||||
searchTimer.Stop();
|
||||
searchTimer.Start();
|
||||
};
|
||||
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
|
||||
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
|
||||
previousButton = new Button { Text = "< Previous", AutoSize = true, Enabled = false };
|
||||
previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync);
|
||||
nextButton = new Button { Text = "Next >", AutoSize = true, Enabled = false };
|
||||
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
|
||||
topPanel.Controls.Add(searchLabel);
|
||||
topPanel.Controls.Add(searchBox);
|
||||
topPanel.Controls.Add(refreshButton);
|
||||
topPanel.Controls.Add(previousButton);
|
||||
topPanel.Controls.Add(nextButton);
|
||||
|
||||
grid = new DataGridView
|
||||
{
|
||||
@@ -56,6 +85,32 @@ public sealed class SavedNestsForm : Form
|
||||
MultiSelect = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
|
||||
};
|
||||
AddColumn("Name", NestSortField.Name, typeof(string));
|
||||
AddColumn("Customer", NestSortField.Customer, typeof(string));
|
||||
AddColumn("Status", NestSortField.Status, typeof(string));
|
||||
AddColumn("Material", NestSortField.Material, typeof(string));
|
||||
AddColumn("Date Created", NestSortField.DateCreated, typeof(DateTime));
|
||||
AddColumn("Date Modified", NestSortField.DateModified, typeof(DateTime));
|
||||
AddColumn("Thickness", NestSortField.Thickness, typeof(double));
|
||||
AddColumn("Plates", NestSortField.PlateCount, typeof(int));
|
||||
AddColumn("Parts", NestSortField.PartCount, typeof(int));
|
||||
AddColumn("Made By", NestSortField.MadeBy, typeof(string));
|
||||
AddColumn("Comments", NestSortField.Comments, typeof(string));
|
||||
AddColumn("File Size", NestSortField.FileSize, typeof(long));
|
||||
AddColumn("Saved", NestSortField.SavedAt, typeof(DateTime));
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = IdColumn,
|
||||
ValueType = typeof(Guid),
|
||||
Visible = false,
|
||||
SortMode = DataGridViewColumnSortMode.NotSortable,
|
||||
});
|
||||
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
|
||||
grid.ColumnHeaderMouseClick += async (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Left && grid.Columns[e.ColumnIndex].Tag is NestSortField field)
|
||||
await RunAsync(() => session.SortByAsync(field));
|
||||
};
|
||||
grid.CellDoubleClick += (_, e) =>
|
||||
{
|
||||
if (e.RowIndex >= 0)
|
||||
@@ -78,6 +133,10 @@ public sealed class SavedNestsForm : Form
|
||||
buttons.Controls.Add(openButton);
|
||||
buttons.Controls.Add(deleteButton);
|
||||
|
||||
var statusStrip = new StatusStrip { SizingGrip = false };
|
||||
statusLabel = new ToolStripStatusLabel { Spring = true, TextAlign = ContentAlignment.MiddleLeft };
|
||||
statusStrip.Items.Add(statusLabel);
|
||||
|
||||
grid.SelectionChanged += (_, _) =>
|
||||
{
|
||||
var hasSelection = grid.SelectedRows.Count > 0;
|
||||
@@ -88,91 +147,72 @@ public sealed class SavedNestsForm : Form
|
||||
Controls.Add(grid);
|
||||
Controls.Add(topPanel);
|
||||
Controls.Add(buttons);
|
||||
Controls.Add(statusStrip);
|
||||
CancelButton = cancel;
|
||||
|
||||
Shown += async (_, _) => await LoadAsync();
|
||||
Shown += async (_, _) => await RunAsync(session.RefreshAsync);
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
private void AddColumn(string name, NestSortField sortField, Type valueType)
|
||||
{
|
||||
Cursor = Cursors.WaitCursor;
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = name,
|
||||
HeaderText = name,
|
||||
ValueType = valueType,
|
||||
SortMode = DataGridViewColumnSortMode.Programmatic,
|
||||
Tag = sortField,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one browse request and renders its page. A request superseded by a newer one
|
||||
/// renders nothing; a failure clears the rows and shows the error in the status line.
|
||||
/// </summary>
|
||||
private async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
pendingRequests++;
|
||||
UpdateNavigation();
|
||||
statusLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var list = await repository.ListAsync();
|
||||
if (IsDisposed)
|
||||
return;
|
||||
records = list.ToArray();
|
||||
ApplyFilter();
|
||||
if (await request() && !IsDisposed)
|
||||
Populate(session.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Could not load nests from the server: {ex.Message}",
|
||||
"Open Nest — Database",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error
|
||||
);
|
||||
{
|
||||
grid.Rows.Clear();
|
||||
statusLabel.Text = $"Could not load nests from the server: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
pendingRequests--;
|
||||
if (!IsDisposed)
|
||||
Cursor = Cursors.Default;
|
||||
{
|
||||
UpdateNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (pendingRequests == 0 && statusLabel.Text == LoadingText)
|
||||
statusLabel.Text = session.Summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
private void UpdateNavigation()
|
||||
{
|
||||
var filter = searchBox.Text.Trim();
|
||||
var filtered = string.IsNullOrEmpty(filter)
|
||||
? records
|
||||
: records
|
||||
.Where(r =>
|
||||
Contains(r.Name, filter)
|
||||
|| Contains(r.Customer, filter)
|
||||
|| Contains(r.Material, filter)
|
||||
|| Contains(r.MadeBy, filter)
|
||||
|| Contains(r.Comments, filter)
|
||||
|| Contains(r.Status.ToString(), filter)
|
||||
|| Contains(FormatStatus(r.Status), filter)
|
||||
|| Contains(r.DateCreated.ToString("g"), filter)
|
||||
|| Contains(r.DateModified.ToString("g"), filter)
|
||||
|| Contains(r.SavedAt.ToString("g"), filter)
|
||||
|| Contains(r.Thickness.ToString(), filter)
|
||||
|| Contains(r.PlateCount.ToString(), filter)
|
||||
|| Contains(r.PartCount.ToString(), filter)
|
||||
|| Contains(r.FileSize.ToString(), filter)
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
Populate(filtered);
|
||||
var idle = pendingRequests == 0;
|
||||
previousButton.Enabled = idle && session.CanGoPrevious;
|
||||
nextButton.Enabled = idle && session.CanGoNext;
|
||||
}
|
||||
|
||||
private static bool Contains(string value, string filter) =>
|
||||
!string.IsNullOrEmpty(value) && value.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void Populate(NestRecord[] rows)
|
||||
private void Populate(NestPage page)
|
||||
{
|
||||
var table = new DataTable();
|
||||
table.Columns.Add("Name", typeof(string));
|
||||
table.Columns.Add("Customer", typeof(string));
|
||||
table.Columns.Add("Status", typeof(string));
|
||||
table.Columns.Add("Material", typeof(string));
|
||||
table.Columns.Add("Date Created", typeof(DateTime));
|
||||
table.Columns.Add("Date Modified", typeof(DateTime));
|
||||
table.Columns.Add("Thickness", typeof(double));
|
||||
table.Columns.Add("Plates", typeof(int));
|
||||
table.Columns.Add("Parts", typeof(int));
|
||||
table.Columns.Add("Made By", typeof(string));
|
||||
table.Columns.Add("Comments", typeof(string));
|
||||
table.Columns.Add("File Size", typeof(long));
|
||||
table.Columns.Add("Saved", typeof(DateTime));
|
||||
table.Columns.Add("Id", typeof(Guid));
|
||||
|
||||
foreach (var record in rows.OrderByDescending(r => r.SavedAt))
|
||||
grid.Rows.Clear();
|
||||
foreach (var record in page.Items)
|
||||
{
|
||||
table.Rows.Add(
|
||||
grid.Rows.Add(
|
||||
record.Name,
|
||||
record.Customer,
|
||||
FormatStatus(record.Status),
|
||||
@@ -190,9 +230,14 @@ public sealed class SavedNestsForm : Form
|
||||
);
|
||||
}
|
||||
|
||||
grid.DataSource = table;
|
||||
if (grid.Columns.Contains("Id"))
|
||||
grid.Columns["Id"].Visible = false;
|
||||
foreach (DataGridViewColumn column in grid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
statusLabel.Text = session.Summary;
|
||||
}
|
||||
|
||||
private static string FormatStatus(NestStatus status) => status switch
|
||||
@@ -207,7 +252,7 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
if (grid.SelectedRows.Count == 0)
|
||||
return;
|
||||
SelectedId = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
|
||||
SelectedId = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
@@ -217,7 +262,7 @@ public sealed class SavedNestsForm : Form
|
||||
if (grid.SelectedRows.Count == 0)
|
||||
return;
|
||||
|
||||
var id = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
|
||||
var id = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
|
||||
var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
@@ -235,7 +280,7 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
await repository.DeleteAsync(id);
|
||||
if (!IsDisposed)
|
||||
await LoadAsync();
|
||||
await RunAsync(session.RefreshAsync);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -254,4 +299,15 @@ public sealed class SavedNestsForm : Form
|
||||
deleteButton.Enabled = grid.SelectedRows.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
searchTimer.Dispose();
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ The desktop app can save nests two ways:
|
||||
|
||||
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
|
||||
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
|
||||
- **Database mode**: Open lists server records with filterable metadata and
|
||||
sortable columns; Save creates a record on first save and updates that same
|
||||
record afterward. Save As creates a new record (a copy). A failed save keeps
|
||||
- **Database mode**: Open browses server records 100 at a time. The filter box
|
||||
searches name, customer, material, made by, comments and status on the server
|
||||
after a short typing pause; clicking a column header sorts every match on the
|
||||
server (click again to reverse); Previous/Next move between pages and the
|
||||
status line shows the range and total. Dates and numbers are not searched as
|
||||
text; sort their column instead. Save creates a record on first save and
|
||||
updates that same record afterward. Save As creates a new record (a copy). A failed save keeps
|
||||
the previous record association; switching server URLs creates a record on the
|
||||
new server rather than updating an id from the previous one. Delete in the
|
||||
saved-nest list permanently removes the selected server record after confirmation.
|
||||
|
||||
Reference in new issue
Block a user