diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e9c1ac..bd34e98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,11 +65,17 @@ jobs: grep -q ':root' wwwroot/input.css || (echo "init did not write :root variables to input.css"; exit 1) grep -q '\.dark' wwwroot/input.css || (echo "init did not write .dark variables to input.css"; exit 1) - shellui add chart pie-chart dashboard-02 --force || true - # Chart components reference ApexCharts.* types but the CLI does not yet - # auto-add the runtime NuGet dep. Drop this line once `shellui add chart` - # declares Blazor-ApexCharts as a nuget dependency. - dotnet add package Blazor-ApexCharts --version 6.0.2 + shellui add chart pie-chart dashboard-02 data-table --force + + # NuGet dependencies (Blazor-ApexCharts, System.Linq.Dynamic.Core) should + # now be added automatically by `shellui add`. Assert they appear in the + # project file so a regression in the auto-install fails loudly here. + grep -q 'Blazor-ApexCharts' SmokeApp.csproj || (echo "shellui add chart did not add Blazor-ApexCharts NuGet dep"; exit 1) + grep -q 'System.Linq.Dynamic.Core' SmokeApp.csproj || (echo "shellui add data-table did not add System.Linq.Dynamic.Core NuGet dep"; exit 1) + + # And the DataTable models file must land at Components/UI/Models/, not be missing. + test -f Components/UI/Models/DataTableModels.cs || (echo "shellui add data-table did not install data-table-models"; exit 1) + dotnet build -c Debug - name: Upload build artifacts diff --git a/ShellUI.Tests/NuGetDepsAndSuggestionsTests.cs b/ShellUI.Tests/NuGetDepsAndSuggestionsTests.cs new file mode 100644 index 0000000..e0833d3 --- /dev/null +++ b/ShellUI.Tests/NuGetDepsAndSuggestionsTests.cs @@ -0,0 +1,104 @@ +using System.Linq; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class RegistrySuggestionsTests +{ + [Theory] + [InlineData("datatable", "data-table")] + [InlineData("data_table", "data-table")] + [InlineData("buttong", "button")] + [InlineData("chrt", "chart")] + [InlineData("themetoggle", "theme-toggle")] + public void FindClosestMatch_SuggestsExpectedComponent(string typo, string expected) + { + Assert.Equal(expected, ComponentRegistry.FindClosestMatch(typo)); + } + + [Fact] + public void FindClosestMatch_ReturnsNullForExactMatch() + { + // Exact matches have distance 0 and are excluded from suggestions — + // the caller already knows the component exists. + Assert.Null(ComponentRegistry.FindClosestMatch("button")); + } + + [Fact] + public void FindClosestMatch_ReturnsNullWhenNothingIsClose() + { + Assert.Null(ComponentRegistry.FindClosestMatch("xyzzy-no-such-component-at-all")); + } + + [Fact] + public void FindClosestMatch_DoesNotSuggestHiddenSubComponents() + { + // `data-table-models` is IsAvailable=false (installed only as a dep of data-table). + // A user typo like "data-table-modls" should not be redirected to it. + var result = ComponentRegistry.FindClosestMatch("data-table-modls"); + Assert.NotEqual("data-table-models", result); + } +} + +public class NuGetDependenciesTests +{ + [Fact] + public void DataTableModels_IsRegisteredAndHidden() + { + var metadata = ComponentRegistry.GetMetadata("data-table-models"); + Assert.NotNull(metadata); + Assert.False(metadata!.IsAvailable, "data-table-models is a sub-component dep and must not appear in `shellui list`"); + } + + [Fact] + public void DataTable_DeclaresSystemLinqDynamicCore() + { + var metadata = ComponentRegistry.GetMetadata("data-table"); + Assert.NotNull(metadata); + Assert.Contains(metadata!.NuGetDependencies, p => p.PackageId == "System.Linq.Dynamic.Core"); + } + + [Fact] + public void Chart_DeclaresBlazorApexCharts() + { + var metadata = ComponentRegistry.GetMetadata("chart"); + Assert.NotNull(metadata); + Assert.Contains(metadata!.NuGetDependencies, p => p.PackageId == "Blazor-ApexCharts"); + } + + [Theory] + [InlineData("pie-chart")] + [InlineData("bar-chart")] + [InlineData("area-chart")] + [InlineData("line-chart")] + [InlineData("multi-series-chart")] + public void ChartVariants_TransitivelyPullInBlazorApexCharts(string componentName) + { + // Chart family components don't declare the NuGet dep themselves — they depend + // on `chart` which does. Verify the dependency chain is intact so the installer's + // recursive walk picks up the package. + var metadata = ComponentRegistry.GetMetadata(componentName); + Assert.NotNull(metadata); + Assert.Contains("chart", metadata!.Dependencies); + } +} + +public class DataTableTemplateContentTests +{ + // The library-wide convention is `Components.Models` for model namespaces regardless + // of where the file lives on disk (TabModels, StepperModels, ContextMenuModels, + // ChartModels all use this). The DataTable @using and DataTableModels namespace + // must agree on that convention so consumers can compile. + [Fact] + public void DataTable_UsingDirectiveMatchesDataTableModelsNamespace() + { + var dataTable = ComponentRegistry.GetComponentContent("data-table"); + var models = ComponentRegistry.GetComponentContent("data-table-models"); + + Assert.NotNull(dataTable); + Assert.NotNull(models); + Assert.Contains("@using YourProjectNamespace.Components.Models", dataTable); + Assert.Contains("namespace YourProjectNamespace.Components.Models;", models); + } +} diff --git a/src/ShellUI.CLI/Services/ComponentInstaller.cs b/src/ShellUI.CLI/Services/ComponentInstaller.cs index aee3f14..3a799a9 100644 --- a/src/ShellUI.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.CLI/Services/ComponentInstaller.cs @@ -10,7 +10,7 @@ public class ComponentInstaller public static async Task InstallComponents(string[] components, bool force) { var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui.json"); - + if (!File.Exists(configPath)) { AnsiConsole.MarkupLine("[red]ShellUI not initialized![/]"); @@ -21,7 +21,7 @@ public static async Task InstallComponents(string[] components, bool force) // Load config var configJson = File.ReadAllText(configPath); var config = JsonSerializer.Deserialize(configJson); - + if (config == null) { AnsiConsole.MarkupLine("[red]Failed to read shellui.json[/]"); @@ -44,7 +44,10 @@ public static async Task InstallComponents(string[] components, bool force) // Track installed components to avoid duplicates var installedSet = new HashSet(); - + // Track NuGet packages requested this batch so we don't re-invoke `dotnet add package` for the same dep + var requestedPackages = new HashSet(StringComparer.OrdinalIgnoreCase); + var pendingNuGetDeps = new List(); + // Show dependency information foreach (var componentName in componentList) { @@ -54,9 +57,9 @@ public static async Task InstallComponents(string[] components, bool force) AnsiConsole.MarkupLine($"[green]●[/] [bold]{componentName}[/] requires: [yellow]{string.Join(", ", metadata.Dependencies)}[/]"); } } - + AnsiConsole.MarkupLine(""); - + await AnsiConsole.Status() .Spinner(Spinner.Known.Dots) .SpinnerStyle(Style.Parse("green")) @@ -65,11 +68,15 @@ await AnsiConsole.Status() foreach (var componentName in componentList) { ctx.Status($"Installing {componentName}..."); - InstallComponentWithDependencies(componentName, config, projectInfo, force, installedSet, ref successCount, ref skippedCount, failedComponents); + InstallComponentWithDependencies(componentName, config, projectInfo, force, installedSet, requestedPackages, pendingNuGetDeps, ref successCount, ref skippedCount, failedComponents); } return Task.CompletedTask; }); + // Install collected NuGet dependencies once, after all source files are in place + // (so `dotnet add package` doesn't restore between every component). + await InstallNuGetDependenciesAsync(projectInfo, pendingNuGetDeps); + // Update config var updatedJson = JsonSerializer.Serialize(config, new JsonSerializerOptions { @@ -203,14 +210,19 @@ private static InstallResult InstallComponentInternal(string componentName, Shel return InstallResult.Success; } - private static void InstallComponentWithDependencies(string componentName, ShellUIConfig config, ProjectInfo projectInfo, bool force, HashSet installedSet, ref int successCount, ref int skippedCount, List failedComponents) + private static void InstallComponentWithDependencies(string componentName, ShellUIConfig config, ProjectInfo projectInfo, bool force, HashSet installedSet, HashSet requestedPackages, List pendingNuGetDeps, ref int successCount, ref int skippedCount, List failedComponents) { if (installedSet.Contains(componentName)) return; // Already processed - + if (!ComponentRegistry.Exists(componentName)) { AnsiConsole.MarkupLine($"[red]Component '{componentName}' not found[/]"); + var suggestion = ComponentRegistry.FindClosestMatch(componentName); + if (suggestion != null) + { + AnsiConsole.MarkupLine($"[yellow]Did you mean '[bold]{suggestion}[/]'?[/]"); + } failedComponents.Add(componentName); return; } @@ -231,14 +243,14 @@ private static void InstallComponentWithDependencies(string componentName, Shell { if (!installedSet.Contains(dep)) { - InstallComponentWithDependencies(dep, config, projectInfo, force, installedSet, ref successCount, ref skippedCount, failedComponents); + InstallComponentWithDependencies(dep, config, projectInfo, force, installedSet, requestedPackages, pendingNuGetDeps, ref successCount, ref skippedCount, failedComponents); } } } // Install the component itself var result = InstallComponentInternal(componentName, config, projectInfo, force); - + if (result == InstallResult.Success) { successCount++; @@ -253,6 +265,66 @@ private static void InstallComponentWithDependencies(string componentName, Shell { failedComponents.Add(componentName); } + + // Collect NuGet deps regardless of source-file install result — even a `Skipped` + // file still requires its NuGet packages to compile. + if (result != InstallResult.Failed && metadata.NuGetDependencies?.Any() == true) + { + foreach (var pkg in metadata.NuGetDependencies) + { + if (requestedPackages.Add(pkg.PackageId)) + { + pendingNuGetDeps.Add(pkg); + } + } + } + } + + private static async Task InstallNuGetDependenciesAsync(ProjectInfo projectInfo, List deps) + { + if (deps.Count == 0) return; + + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine($"[cyan]Adding {deps.Count} NuGet package reference(s)...[/]"); + + foreach (var dep in deps) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = { "add", projectInfo.ProjectPath, "package", dep.PackageId, "--version", dep.Version }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + try + { + using var process = System.Diagnostics.Process.Start(startInfo); + if (process == null) + { + AnsiConsole.MarkupLine($"[yellow]Warning:[/] could not start `dotnet add package` for {dep.PackageId}"); + continue; + } + + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + AnsiConsole.MarkupLine($"[green]Added:[/] {dep.PackageId} {dep.Version}"); + } + else + { + var err = (await process.StandardError.ReadToEndAsync()).Trim(); + AnsiConsole.MarkupLine($"[yellow]Warning:[/] failed to add {dep.PackageId}: {err.Replace("[", "[[").Replace("]", "]]")}"); + } + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[yellow]Warning:[/] could not add {dep.PackageId}: {ex.Message.Replace("[", "[[").Replace("]", "]]")}"); + } + } } private enum InstallResult diff --git a/src/ShellUI.Core/Models/ComponentMetadata.cs b/src/ShellUI.Core/Models/ComponentMetadata.cs index 52e3738..4e93c77 100644 --- a/src/ShellUI.Core/Models/ComponentMetadata.cs +++ b/src/ShellUI.Core/Models/ComponentMetadata.cs @@ -12,6 +12,12 @@ public class ComponentMetadata public required ComponentCategory Category { get; set; } public List Dependencies { get; set; } = new(); + // NuGet packages the rendered component references at compile time (e.g. Chart + // uses Blazor-ApexCharts; DataTable uses System.Linq.Dynamic.Core). The CLI + // runs `dotnet add package` for each on install — without this the consumer + // sees CS0246 errors after `shellui add`. + public List NuGetDependencies { get; set; } = new(); + // Relative to Components/UI folder (or LayoutPath when IsLayoutBlock is true) public required string FilePath { get; set; } diff --git a/src/ShellUI.Core/Models/NuGetDependency.cs b/src/ShellUI.Core/Models/NuGetDependency.cs new file mode 100644 index 0000000..d739f79 --- /dev/null +++ b/src/ShellUI.Core/Models/NuGetDependency.cs @@ -0,0 +1,7 @@ +namespace ShellUI.Core.Models; + +public class NuGetDependency +{ + public required string PackageId { get; init; } + public required string Version { get; init; } +} diff --git a/src/ShellUI.Templates/ComponentRegistry.cs b/src/ShellUI.Templates/ComponentRegistry.cs index 35aa2eb..56238e0 100644 --- a/src/ShellUI.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Templates/ComponentRegistry.cs @@ -133,6 +133,7 @@ public static class ComponentRegistry { "collapsible-trigger", CollapsibleTriggerTemplate.Metadata }, { "collapsible-content", CollapsibleContentTemplate.Metadata }, { "data-table", DataTableTemplate.Metadata }, + { "data-table-models", DataTableModelsTemplate.Metadata }, { "alert-dialog", AlertDialogTemplate.Metadata }, { "calendar", CalendarTemplate.Metadata }, { "loading", LoadingTemplate.Metadata }, @@ -303,6 +304,7 @@ public static class ComponentRegistry "collapsible-trigger" => CollapsibleTriggerTemplate.Content, "collapsible-content" => CollapsibleContentTemplate.Content, "data-table" => DataTableTemplate.Content, + "data-table-models" => DataTableModelsTemplate.Content, "alert-dialog" => AlertDialogTemplate.Content, "calendar" => CalendarTemplate.Content, "loading" => LoadingTemplate.Content, @@ -364,5 +366,54 @@ public static bool Exists(string componentName) { return Components.ContainsKey(componentName.ToLower()); } + + // Returns the closest installable component name to `query` within edit distance 3, + // or null if nothing is close enough. Used to power "did you mean …?" hints when a + // user mistypes (e.g. `datatable` → `data-table`). Excludes hidden sub-components. + public static string? FindClosestMatch(string query) + { + var lower = query.ToLowerInvariant(); + string? best = null; + var bestDistance = int.MaxValue; + + foreach (var (name, metadata) in Components) + { + if (!metadata.IsAvailable) continue; + var d = LevenshteinDistance(lower, name); + if (d == 0 || d > 3) continue; + if (d < bestDistance || (d == bestDistance && string.CompareOrdinal(name, best) < 0)) + { + best = name; + bestDistance = d; + } + } + + return best; + } + + private static int LevenshteinDistance(string a, string b) + { + if (a.Length == 0) return b.Length; + if (b.Length == 0) return a.Length; + + var prev = new int[b.Length + 1]; + var curr = new int[b.Length + 1]; + for (var j = 0; j <= b.Length; j++) prev[j] = j; + + for (var i = 1; i <= a.Length; i++) + { + curr[0] = i; + for (var j = 1; j <= b.Length; j++) + { + var cost = a[i - 1] == b[j - 1] ? 0 : 1; + curr[j] = Math.Min( + Math.Min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost); + } + (prev, curr) = (curr, prev); + } + + return prev[b.Length]; + } } diff --git a/src/ShellUI.Templates/Templates/ChartTemplate.cs b/src/ShellUI.Templates/Templates/ChartTemplate.cs index 49c9751..478e9e8 100644 --- a/src/ShellUI.Templates/Templates/ChartTemplate.cs +++ b/src/ShellUI.Templates/Templates/ChartTemplate.cs @@ -12,6 +12,10 @@ public class ChartTemplate Category = ComponentCategory.DataDisplay, FilePath = "Chart.razor", Dependencies = new List { "chart-variants" }, + NuGetDependencies = new List + { + new() { PackageId = "Blazor-ApexCharts", Version = "6.0.2" } + }, Variants = new List { "default", "colorful", "monochrome" }, Tags = new List { "chart", "data", "visualization", "apexcharts" } }; diff --git a/src/ShellUI.Templates/Templates/DataTableModelsTemplate.cs b/src/ShellUI.Templates/Templates/DataTableModelsTemplate.cs index 860e758..a5b8c38 100644 --- a/src/ShellUI.Templates/Templates/DataTableModelsTemplate.cs +++ b/src/ShellUI.Templates/Templates/DataTableModelsTemplate.cs @@ -11,7 +11,7 @@ public static class DataTableModelsTemplate Description = "Models for DataTable component", Category = ComponentCategory.DataDisplay, FilePath = "Models/DataTableModels.cs", - + IsAvailable = false, Dependencies = new List() }; diff --git a/src/ShellUI.Templates/Templates/DataTableTemplate.cs b/src/ShellUI.Templates/Templates/DataTableTemplate.cs index a7a12d3..62c168b 100644 --- a/src/ShellUI.Templates/Templates/DataTableTemplate.cs +++ b/src/ShellUI.Templates/Templates/DataTableTemplate.cs @@ -11,8 +11,11 @@ public static class DataTableTemplate Description = "Advanced data table with sorting, filtering, and pagination", Category = ComponentCategory.DataDisplay, FilePath = "DataTable.razor", - - Dependencies = new List { "data-table-models" } + Dependencies = new List { "data-table-models" }, + NuGetDependencies = new List + { + new() { PackageId = "System.Linq.Dynamic.Core", Version = "1.7.1" } + } }; public static string Content => @"@typeparam TItem