Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@
</div>
<Pagination CurrentPage="CurrentPage"
TotalPages="TotalPages"
OnPageChange="HandlePageChange" />
CurrentPageChanged="HandlePageChange" />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ ShellUI is pre-1.0, so APIs and generated output can still change between minor

## Capabilities

- The CLI commands are `init`, `add`, `list`, `remove`, `update` and `doctor`, plus `theme init`, `theme apply`, and `theme update`.
- The CLI commands are `init`, `add`, `list`, `remove`, `update`, `doctor` and `migrate`, plus `theme init`, `theme apply`, and `theme update`.
- The component registry has **254 entries**: **98 direct install targets** and **156 hidden dependency entries**. `list` shows direct targets; `add` resolves hidden dependencies.
- `0.4.0-alpha.1` adds `kbd`, `aspect-ratio`, `button-group`, `toggle-group`, `input-group`, `number-input`, `stat-card`, `timeline`, `tree-view`, `qr-code`, `image-viewer`, `chat`, `chat-message`, `chat-input`, and the `auth-01`, `auth-02` and `auth-03` blocks.
- `0.3.0` added `typed-select`, `command-palette`, `data-picker`, `multi-select`, `tag-input`, `donut-chart`, `radar-chart`, and `radial-chart`.
Expand Down
126 changes: 126 additions & 0 deletions ShellUI.Tests/MigrateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using ShellUI.CLI.Services;

namespace ShellUI.Tests;

public class MigrateTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"migrate-{Guid.NewGuid():N}");

public MigrateTests()
{
Directory.CreateDirectory(Path.Combine(_root, "Components", "UI"));
Directory.CreateDirectory(Path.Combine(_root, "Components", "Pages"));
Directory.CreateDirectory(Path.Combine(_root, "obj"));
File.WriteAllText(Path.Combine(_root, "MyApp.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk.Web\"></Project>");
}

public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}

private string Page(string name, string content)
{
var path = Path.Combine(_root, "Components", "Pages", name);
File.WriteAllText(path, content);
return path;
}

private MigrationEdit SingleEdit(params string[] components) => Assert.Single(MigrateService.Plan(_root, components).Edits);

[Fact]
public void Input_DropsTheNestedVariantEnum()
{
Page("Login.razor", "<Input Variant=\"Input.InputVariant.Error\" />\n@code { var v = Input.InputVariant.Default; }");

var edit = SingleEdit("input");

Assert.Equal("<Input Variant=\"InputVariant.Error\" />\n@code { var v = InputVariant.Default; }", edit.After);
}

[Fact]
public void DatePicker_RenamesSelectedDateButLeavesCalendarAlone()
{
Page("Booking.razor",
"<DatePicker @bind-SelectedDate=\"_date\" @bind-SelectedDate:after=\"Save\" />\n" +
"<DatePicker SelectedDate=\"_other\"\n SelectedDateChanged=\"OnPicked\" Placeholder=\"a > b\" />\n" +
"<Calendar @bind-SelectedDate=\"_day\" />");

var edit = SingleEdit("date-picker");

Assert.Equal(
"<DatePicker @bind-Value=\"_date\" @bind-Value:after=\"Save\" />\n" +
"<DatePicker Value=\"_other\"\n ValueChanged=\"OnPicked\" Placeholder=\"a > b\" />\n" +
"<Calendar @bind-SelectedDate=\"_day\" />", edit.After);
}

[Fact]
public void TimePicker_RenamesSelectedTime()
{
Page("Alarm.razor", "<TimePicker @bind-SelectedTime=\"_time\" Step=\"15\" />");

Assert.Equal("<TimePicker @bind-Value=\"_time\" Step=\"15\" />", SingleEdit("time-picker").After);
}

[Fact]
public void Pagination_RenamesOnPageChangeUnlessCurrentPageIsBound()
{
Page("List.razor",
"<Pagination CurrentPage=\"_page\" TotalPages=\"9\" OnPageChange=\"Load\" />\n" +
"<Pagination @bind-CurrentPage=\"_page\" OnPageChange=\"Log\" />");

var edit = SingleEdit("pagination");

Assert.Equal(
"<Pagination CurrentPage=\"_page\" TotalPages=\"9\" CurrentPageChanged=\"Load\" />\n" +
"<Pagination @bind-CurrentPage=\"_page\" OnPageChange=\"Log\" />", edit.After);
}

[Fact]
public void Plan_SkipsInstalledComponentsAndBuildOutput()
{
File.WriteAllText(Path.Combine(_root, "Components", "UI", "Input.razor"), "Input.InputVariant.Error");
File.WriteAllText(Path.Combine(_root, "obj", "Generated.cs"), "Input.InputVariant.Error");
var page = Page("Form.razor", "Input.InputVariant.Error");

var plan = MigrateService.Plan(_root);

Assert.Equal(page, Assert.Single(plan.Edits).Path);
}

[Fact]
public void Plan_WithNoComponentsRunsEveryMigrationAndListsWhatItApplied()
{
Page("All.razor", "<TimePicker SelectedTime=\"_t\" />\n<Input Variant=\"Input.InputVariant.Error\" />");

var edit = SingleEdit();

Assert.Equal(2, edit.Applied.Count);
Assert.Equal("<TimePicker Value=\"_t\" />\n<Input Variant=\"InputVariant.Error\" />", edit.After);
}

[Fact]
public void Calendar_FlagsHandlersThatTakeADateTime()
{
var page = Page("Plan.razor",
"<Calendar SelectedDateChanged=\"OnDay\" />\n@code {\n void OnDay(DateTime day) { }\n void Other(DateTime d) { }\n}");

var plan = MigrateService.Plan(_root);

Assert.Empty(plan.Edits);
var note = Assert.Single(plan.Notes);
Assert.Equal(page, note.Path);
Assert.Equal(3, note.Line);
}

[Fact]
public void Plan_IsEmptyForAnUpToDateApp()
{
Page("Fine.razor", "<DatePicker @bind-Value=\"_d\" />\n<Calendar SelectedDateChanged=\"OnDay\" />\n@code { void OnDay(DateTime? d) { } }");

var plan = MigrateService.Plan(_root);

Assert.Empty(plan.Edits);
Assert.Empty(plan.Notes);
}
}
26 changes: 25 additions & 1 deletion docs/CLI_SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ shellui list [--installed|--available]
shellui remove <name...>
shellui update [name...] [--all] [--force]
shellui doctor
shellui migrate [name...] [--dry-run] [--yes]
shellui theme init <url-or-id> [--force] [--style <style>] [--tailwind standalone|npm] [--yes] [--dashboard 01|02|none] [--replace-layout]
shellui theme apply <url-or-id> [--emit-override <path>]
shellui theme update
Expand Down Expand Up @@ -168,7 +169,7 @@ Names are separated by spaces. With no names, or with `--all`, every installed c
- **Your changes are kept.** `shellui.json` records a hash of each file as the CLI wrote it. If a file has changed since, `update` skips it and says so; `--force` overwrites it. Files installed by a CLI older than 0.4.0-alpha.2 have no hash, so for those `update` keeps the blocks apps are meant to edit (`app-sidebar`, `sidebar-account`, the `dashboard-*` and `auth-*` layouts) when they differ from the template, and rewrites everything else.
- **New dependencies are installed.** When a component gained dependencies since you installed it (for example `tabs` now needs `tab-models`), `update` adds them, with their NuGet packages and `_Imports.razor` usings, the same way `add` does.

`update` has no diff or merge. Commit before updating so you can review the changes.
`update` has no diff or merge. Commit before updating so you can review the changes. When a template's API changed, run `shellui migrate` afterwards to update your pages.

## `doctor`

Expand All @@ -188,6 +189,29 @@ shellui doctor

It exits with code 1 when it finds a problem, so it can run in CI. Warnings, such as a stale `app.css`, don't change the exit code. In a Razor class library it skips the host page checks, which apply to each app that uses the library.

## `migrate`

Rewrites your own pages for template API changes listed in the release notes, after `shellui update` has replaced the components:

```bash
shellui migrate --dry-run
shellui migrate
shellui migrate date-picker pagination
shellui migrate --yes
```

It reads the project's `.razor` and `.cs` files (not the installed components under `ComponentsPath`, or `bin`, `obj` and `node_modules`), shows each changed line, and asks before writing. `--dry-run` only shows the changes; `--yes` writes them without asking, and is required when the terminal isn't interactive. With no names it runs every migration.

| Name | Change |
|---|---|
| `input` | `Input.InputVariant.Error` → `InputVariant.Error` (0.4.0-alpha.3) |
| `date-picker` | `SelectedDate`, `SelectedDateChanged` and `@bind-SelectedDate` → `Value`, `ValueChanged` and `@bind-Value`, on `<DatePicker>` only (0.4.0-alpha.3) |
| `time-picker` | the same for `<TimePicker>`'s `SelectedTime` (0.4.0-alpha.3) |
| `pagination` | `OnPageChange` → `CurrentPageChanged`, except on a tag that already uses `@bind-CurrentPage` (0.4.0-alpha.3) |
| `calendar` | Reports handlers of `Calendar.SelectedDateChanged` that take a `DateTime`; they need a `DateTime?` since 0.4.0-alpha.1. Nothing is rewritten. |

Commit first, then build the app afterwards.

## `theme`

The theme commands fetch a public tweakcn theme by URL or theme ID and write its CSS variables into the project.
Expand Down
38 changes: 38 additions & 0 deletions src/ShellUI.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ static async Task<int> Main(string[] args)
rootCommand.AddCommand(CreateUpdateCommand());
rootCommand.AddCommand(CreateThemeCommand());
rootCommand.AddCommand(CreateDoctorCommand());
rootCommand.AddCommand(CreateMigrateCommand());

return await rootCommand.InvokeAsync(args);
}
Expand Down Expand Up @@ -286,6 +287,43 @@ static Command CreateDoctorCommand()
return command;
}

static Command CreateMigrateCommand()
{
var command = new Command("migrate", "Rewrite your pages for template API changes, showing a diff and asking first");

var componentsArg = new Argument<string[]>(
"components",
$"Component(s) to migrate (empty = all): {string.Join(", ", MigrateService.Components)}")
{
Arity = ArgumentArity.ZeroOrMore
};
command.AddArgument(componentsArg);

var dryRunOption = new Option<bool>("--dry-run", "Show the changes without writing them");
command.AddOption(dryRunOption);

var yesOption = new Option<bool>("--yes", "Write the changes without asking");
command.AddOption(yesOption);

command.SetHandler(context =>
{
try
{
context.ExitCode = MigrateService.Run(
context.ParseResult.GetValueForArgument(componentsArg),
context.ParseResult.GetValueForOption(dryRunOption),
context.ParseResult.GetValueForOption(yesOption));
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
context.ExitCode = 1;
}
});

return command;
}

static Command CreateListCommand()
{
var command = new Command("list", "List available components");
Expand Down
Loading
Loading