From 240b407f5dbda7c73077f45bf1884d1257c69cce Mon Sep 17 00:00:00 2001 From: luckydizzier <134082331+luckydizzier@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:23:59 +0200 Subject: [PATCH] feat(demo-app): deliver catalog shell milestone --- src/demo/XBase.Demo.App/App.axaml | 29 +++ .../ViewModels/IndexListItemViewModel.cs | 26 +++ .../ViewModels/ShellViewModel.cs | 196 ++++++++++++++---- .../ViewModels/TableListItemViewModel.cs | 32 +++ .../ViewModels/TablePageViewModel.cs | 63 ++++++ .../XBase.Demo.App/Views/MainWindow.axaml | 56 ++++- .../XBase.Demo.App/Views/MainWindow.axaml.cs | 27 +++ src/demo/tasks.md | 10 +- .../ShellViewModelTests.cs | 92 ++++++++ .../XBase.Demo.App.Tests.csproj | 28 +++ xBase.sln | 7 + 11 files changed, 516 insertions(+), 50 deletions(-) create mode 100644 src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs create mode 100644 src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs create mode 100644 src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs create mode 100644 tests/XBase.Demo.App.Tests/ShellViewModelTests.cs create mode 100644 tests/XBase.Demo.App.Tests/XBase.Demo.App.Tests.csproj diff --git a/src/demo/XBase.Demo.App/App.axaml b/src/demo/XBase.Demo.App/App.axaml index ed4fd08..b85c23e 100644 --- a/src/demo/XBase.Demo.App/App.axaml +++ b/src/demo/XBase.Demo.App/App.axaml @@ -1,5 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs new file mode 100644 index 0000000..3d182d3 --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs @@ -0,0 +1,26 @@ +using System; +using XBase.Demo.Domain.Catalog; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Represents an index entry for the selected table. +/// +public sealed class IndexListItemViewModel +{ + public IndexListItemViewModel(IndexModel model) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + Name = model.Name; + Expression = model.Expression; + Order = model.Order; + } + + public IndexModel Model { get; } + + public string Name { get; } + + public string Expression { get; } + + public int Order { get; } +} diff --git a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs index d2df08e..2bd724e 100644 --- a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs +++ b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Reactive; using System.Reactive.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ReactiveUI; +using System.Reactive.Threading.Tasks; using XBase.Demo.Domain.Catalog; using XBase.Demo.Domain.Diagnostics; using XBase.Demo.Domain.Services; @@ -19,8 +22,9 @@ public class ShellViewModel : ReactiveObject private readonly ILogger _logger; private string? _catalogRoot; - private string? _tableSummary; private bool _isBusy; + private TableListItemViewModel? _selectedTable; + private string? _catalogStatus; public ShellViewModel( ITableCatalogService catalogService, @@ -33,11 +37,49 @@ public ShellViewModel( _telemetrySink = telemetrySink; _logger = logger; - OpenCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteOpenCatalogAsync); + Tables = new ReadOnlyObservableCollection(_tables); + TelemetryEvents = new ReadOnlyObservableCollection(_telemetryEvents); + TablePage = new TablePageViewModel(); + + var isIdle = this.WhenAnyValue(x => x.IsBusy).Select(isBusy => !isBusy); + + OpenCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteOpenCatalogAsync, isIdle); + OpenCatalogCommand.Subscribe(OnCatalogLoaded); OpenCatalogCommand.ThrownExceptions.Subscribe(OnOpenCatalogFault); + + BrowseCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteBrowseCatalogAsync, isIdle); + BrowseCatalogCommand.ThrownExceptions.Subscribe(ex => _logger.LogError(ex, "Catalog browse failed")); + + var canRefresh = this.WhenAnyValue(x => x.CatalogRoot, x => x.IsBusy, (root, busy) => !busy && !string.IsNullOrWhiteSpace(root)); + RefreshCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteRefreshCatalogAsync, canRefresh); + + LoadTableCommand = ReactiveCommand.CreateFromTask(ExecuteLoadTableAsync); + LoadTableCommand.ThrownExceptions.Subscribe(OnLoadTableFault); + + Observable.CombineLatest( + OpenCatalogCommand.IsExecuting.StartWith(false), + LoadTableCommand.IsExecuting.StartWith(false), + (isCatalogExecuting, isTableExecuting) => isCatalogExecuting || isTableExecuting) + .DistinctUntilChanged() + .Subscribe(executing => IsBusy = executing); + + this.WhenAnyValue(x => x.SelectedTable) + .WhereNotNull() + .InvokeCommand(LoadTableCommand); } - public ReactiveCommand OpenCatalogCommand { get; } + private readonly ObservableCollection _tables = new(); + private readonly ObservableCollection _telemetryEvents = new(); + + public Interaction SelectCatalogFolderInteraction { get; } = new(); + + public ReactiveCommand OpenCatalogCommand { get; } + + public ReactiveCommand BrowseCatalogCommand { get; } + + public ReactiveCommand RefreshCatalogCommand { get; } + + public ReactiveCommand LoadTableCommand { get; } public string? CatalogRoot { @@ -45,10 +87,10 @@ public string? CatalogRoot private set => this.RaiseAndSetIfChanged(ref _catalogRoot, value); } - public string? TableSummary + public string? CatalogStatus { - get => _tableSummary; - private set => this.RaiseAndSetIfChanged(ref _tableSummary, value); + get => _catalogStatus; + private set => this.RaiseAndSetIfChanged(ref _catalogStatus, value); } public bool IsBusy @@ -57,41 +99,52 @@ public bool IsBusy private set => this.RaiseAndSetIfChanged(ref _isBusy, value); } - private async Task ExecuteOpenCatalogAsync(string rootPath) + public ReadOnlyObservableCollection Tables { get; } + + public TableListItemViewModel? SelectedTable { - if (string.IsNullOrWhiteSpace(rootPath)) + get => _selectedTable; + set => this.RaiseAndSetIfChanged(ref _selectedTable, value); + } + + public TablePageViewModel TablePage { get; } + + public ReadOnlyObservableCollection TelemetryEvents { get; } + + private async Task ExecuteBrowseCatalogAsync() + { + var folder = await SelectCatalogFolderInteraction.Handle(Unit.Default); + if (string.IsNullOrWhiteSpace(folder)) { - return; + return Unit.Default; } - try + await OpenCatalogCommand.Execute(folder).ToTask(); + return Unit.Default; + } + + private async Task ExecuteRefreshCatalogAsync() + { + var root = CatalogRoot; + if (string.IsNullOrWhiteSpace(root)) { - IsBusy = true; - CatalogRoot = rootPath; - - var catalog = await _catalogService.LoadCatalogAsync(rootPath); - var payload = new Dictionary - { - ["root"] = catalog.RootPath, - ["tableCount"] = catalog.Tables.Count - }; - _telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload)); - - if (catalog.Tables.Count > 0) - { - var firstTable = catalog.Tables[0]; - var page = await _pageService.LoadPageAsync(firstTable, new TablePageRequest(0, 25)); - TableSummary = $"{catalog.Tables.Count} tables discovered. Preview rows: {page.Rows.Count} from {firstTable.Name}."; - } - else - { - TableSummary = "Catalog scanned successfully with no tables detected."; - } + return Unit.Default; } - finally + + await OpenCatalogCommand.Execute(root).ToTask(); + return Unit.Default; + } + + private async Task ExecuteOpenCatalogAsync(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) { - IsBusy = false; + return new CatalogModel(string.Empty, Array.Empty()); } + + CatalogRoot = rootPath; + var catalog = await _catalogService.LoadCatalogAsync(rootPath); + return catalog; } private void OnOpenCatalogFault(Exception exception) @@ -101,7 +154,80 @@ private void OnOpenCatalogFault(Exception exception) { ["message"] = exception.Message }; - _telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload)); - TableSummary = "Catalog load failed. Review diagnostics for details."; + RecordTelemetry(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload)); + CatalogStatus = "Catalog load failed. Review diagnostics for details."; + _tables.Clear(); + SelectedTable = null; + } + + private void OnCatalogLoaded(CatalogModel catalog) + { + CatalogRoot = catalog.RootPath; + _tables.Clear(); + + foreach (var table in catalog.Tables.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)) + { + _tables.Add(new TableListItemViewModel(table)); + } + + CatalogStatus = _tables.Count > 0 + ? $"{_tables.Count} tables discovered. Select a table to preview rows." + : "Catalog scanned successfully with no tables detected."; + + if (_tables.Count == 0) + { + var emptyRows = Array.Empty>(); + TablePage.Apply(new TablePage(emptyRows, 0, 0, TablePage.PageSize)); + } + + var payload = new Dictionary + { + ["root"] = catalog.RootPath, + ["tableCount"] = catalog.Tables.Count + }; + RecordTelemetry(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload)); + + SelectedTable = _tables.FirstOrDefault(); + } + + private async Task ExecuteLoadTableAsync(TableListItemViewModel table) + { + var request = new TablePageRequest(0, 25); + var page = await _pageService.LoadPageAsync(table.Model, request); + TablePage.Apply(page); + + CatalogStatus = _tables.Count > 0 + ? $"{_tables.Count} tables available. Showing {table.Name}." + : CatalogStatus; + + var payload = new Dictionary + { + ["table"] = table.Name, + ["indexes"] = table.Indexes.Count, + ["rows"] = page.Rows.Count + }; + RecordTelemetry(new DemoTelemetryEvent("TablePreviewLoaded", DateTimeOffset.UtcNow, payload)); + } + + private void OnLoadTableFault(Exception exception) + { + _logger.LogError(exception, "Table preview load failed"); + var payload = new Dictionary + { + ["message"] = exception.Message + }; + RecordTelemetry(new DemoTelemetryEvent("TablePreviewFailed", DateTimeOffset.UtcNow, payload)); + CatalogStatus = "Table preview failed. Check diagnostics for more information."; + } + + private void RecordTelemetry(DemoTelemetryEvent telemetryEvent) + { + _telemetrySink.Publish(telemetryEvent); + + _telemetryEvents.Insert(0, telemetryEvent); + while (_telemetryEvents.Count > 64) + { + _telemetryEvents.RemoveAt(_telemetryEvents.Count - 1); + } } } diff --git a/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs new file mode 100644 index 0000000..291667c --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using XBase.Demo.Domain.Catalog; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Represents a single table entry displayed in the catalog browser. +/// +public sealed class TableListItemViewModel +{ + public TableListItemViewModel(TableModel model) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + Name = model.Name; + Path = model.Path; + Indexes = model.Indexes + .OrderBy(index => index.Order) + .ThenBy(index => index.Name, StringComparer.OrdinalIgnoreCase) + .Select(index => new IndexListItemViewModel(index)) + .ToArray(); + } + + public TableModel Model { get; } + + public string Name { get; } + + public string Path { get; } + + public IReadOnlyList Indexes { get; } +} diff --git a/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs new file mode 100644 index 0000000..04fca26 --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using ReactiveUI; +using XBase.Demo.Domain.Catalog; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Represents the placeholder table page information shown in the browser. +/// +public sealed class TablePageViewModel : ReactiveObject +{ + private IReadOnlyList> _rows = Array.Empty>(); + private long _totalCount; + private int _pageNumber; + private int _pageSize = 25; + + public IReadOnlyList> Rows + { + get => _rows; + private set => this.RaiseAndSetIfChanged(ref _rows, value); + } + + public long TotalCount + { + get => _totalCount; + private set => this.RaiseAndSetIfChanged(ref _totalCount, value); + } + + public int PageNumber + { + get => _pageNumber; + private set => this.RaiseAndSetIfChanged(ref _pageNumber, value); + } + + public int PageSize + { + get => _pageSize; + private set => this.RaiseAndSetIfChanged(ref _pageSize, value); + } + + public string Summary + { + get + { + var pageIndex = PageNumber + 1; + var totalPages = PageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize)); + return $"Page {pageIndex} of {totalPages} · Showing {Rows.Count} rows (Total {TotalCount})."; + } + } + + public void Apply(TablePage page) + { + ArgumentNullException.ThrowIfNull(page); + + Rows = page.Rows; + TotalCount = page.TotalCount; + PageNumber = page.PageNumber; + PageSize = page.PageSize; + + this.RaisePropertyChanged(nameof(Summary)); + } +} diff --git a/src/demo/XBase.Demo.App/Views/MainWindow.axaml b/src/demo/XBase.Demo.App/Views/MainWindow.axaml index f0fce1d..5505dd8 100644 --- a/src/demo/XBase.Demo.App/Views/MainWindow.axaml +++ b/src/demo/XBase.Demo.App/Views/MainWindow.axaml @@ -4,19 +4,55 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" x:Class="XBase.Demo.App.Views.MainWindow" - Width="1024" - Height="640" + Width="1100" + Height="680" Title="xBase Demo Shell" mc:Ignorable="d"> - - - - - + + +