diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524c0a0..9192f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Test Validation run: dotnet test packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj -c Release --no-build + - name: Test UI + run: dotnet test packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/ZibStack.NET.UI.Tests.csproj -c Release --no-build + - name: Benchmark Log run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json diff --git a/.github/workflows/release-all.yml b/.github/workflows/release-all.yml index 651f998..44151de 100644 --- a/.github/workflows/release-all.yml +++ b/.github/workflows/release-all.yml @@ -53,6 +53,9 @@ jobs: - name: Pack 7 — Validation run: dotnet pack packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj -c Release -o ./artifacts -p:PackageVersion=${{ inputs.version }} + - name: Pack 8 — UI + run: dotnet pack packages/ZibStack.NET.UI/src/ZibStack.NET.UI/ZibStack.NET.UI.csproj -c Release -o ./artifacts -p:PackageVersion=${{ inputs.version }} + # Push all at once (NuGet resolves deps from same push) - name: Push to NuGet run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml new file mode 100644 index 0000000..5cd19f9 --- /dev/null +++ b/.github/workflows/release-ui.yml @@ -0,0 +1,30 @@ +name: Release ZibStack.NET.UI + +on: + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g. 1.0.0)' + required: true + type: string + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - run: dotnet build ZibStack.NET.slnx -c Release + - run: dotnet test packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/ZibStack.NET.UI.Tests.csproj -c Release --no-build + - run: dotnet pack packages/ZibStack.NET.UI/src/ZibStack.NET.UI/ZibStack.NET.UI.csproj -c Release -o ./artifacts -p:PackageVersion=${{ inputs.version }} + - run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + - uses: softprops/action-gh-release@v2 + with: + tag_name: ui-v${{ inputs.version }} + name: ZibStack.NET.UI v${{ inputs.version }} + generate_release_notes: true + files: ./artifacts/*.nupkg diff --git a/README.md b/README.md index 15f4c11..a4e29f9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A collection of .NET source generators and utilities for common application conc | [**ZibStack.NET.Dto**](packages/ZibStack.NET.Dto/) | `dotnet add package ZibStack.NET.Dto` | Source generator for strongly-typed Create and Update request DTOs with PatchField support. | | [**ZibStack.NET.Result**](packages/ZibStack.NET.Result/) | `dotnet add package ZibStack.NET.Result` | Functional Result monad (`Result`) with Map/Bind/Match, error handling without exceptions. | | [**ZibStack.NET.Validation**](packages/ZibStack.NET.Validation/) | `dotnet add package ZibStack.NET.Validation` | Source generator for compile-time validation from attributes (`[Required]`, `[Email]`, `[Range]`, `[Match]`). | +| [**ZibStack.NET.UI**](packages/ZibStack.NET.UI/) | `dotnet add package ZibStack.NET.UI` | Source generator for UI form and table metadata — forms, tables, drill-down, row/toolbar actions, permissions, conditional styling. | ## Quick Examples @@ -146,6 +147,38 @@ var result = request.Validate(); if (!result.IsValid) return BadRequest(result.Errors); ``` +### ZibStack.NET.UI + +```csharp +// Annotate your model — get form + table + ERP metadata: +[Form] +[Table(DefaultSort = "Name")] +[ChildTable(typeof(CountyView), ForeignKey = "VoivodeshipId", Label = "Powiaty")] +[RowAction("report", Label = "Raport", Endpoint = "/api/{id}/report", Method = "POST")] +[ToolbarAction("export", Label = "Eksport", Endpoint = "/api/export", SelectionMode = "multiple")] +[Permission("voivodeship.read")] +public partial class VoivodeshipView +{ + [FormField(Label = "Nazwa")] + [TableColumn(Sortable = true, Filterable = true)] + public string Name { get; set; } = ""; + + [Computed] + [ColumnStyle(When = "value < 0", Severity = "danger")] + [ColumnStyle(When = "value >= 0", Severity = "success")] + public decimal Budget { get; set; } + + [Select(typeof(Region))] + public Region Region { get; set; } +} + +// Serve JSON schema to any frontend: +app.MapGet("/api/forms/voivodeship", () => + Results.Content(VoivodeshipView.GetFormSchemaJson(), "application/json")); +app.MapGet("/api/tables/voivodeship", () => + Results.Content(VoivodeshipView.GetTableSchemaJson(), "application/json")); +``` + ## Repository Structure ``` @@ -165,14 +198,21 @@ ZibStack.NET/ │ ├── ZibStack.NET.Result/ → Result monad (Map/Bind/Match) │ │ ├── src/ → Library │ │ └── tests/ → Unit tests -│ └── ZibStack.NET.Validation/ → Validation source generator +│ ├── ZibStack.NET.Validation/ → Validation source generator +│ │ ├── src/ → Generator +│ │ └── tests/ → Unit tests +│ └── ZibStack.NET.UI/ → UI metadata source generator │ ├── src/ → Generator -│ └── tests/ → Unit tests +│ ├── tests/ → Unit tests +│ └── sample/ → API + Blazor + React samples ├── .github/workflows/ │ ├── ci.yml → Builds & tests all packages │ ├── release-aop.yml → Release ZibStack.NET.Aop to NuGet │ ├── release-log.yml → Release ZibStack.NET.Log to NuGet -│ └── release-dto.yml → Release ZibStack.NET.Dto to NuGet +│ ├── release-dto.yml → Release ZibStack.NET.Dto to NuGet +│ ├── release-result.yml → Release ZibStack.NET.Result to NuGet +│ ├── release-validation.yml → Release ZibStack.NET.Validation to NuGet +│ └── release-ui.yml → Release ZibStack.NET.UI to NuGet └── ZibStack.NET.slnx ``` diff --git a/ZibStack.NET.slnx b/ZibStack.NET.slnx index be41c5a..4f6b4d4 100644 --- a/ZibStack.NET.slnx +++ b/ZibStack.NET.slnx @@ -49,4 +49,15 @@ + + + + + + + + + + + diff --git a/packages/ZibStack.NET.UI/README.md b/packages/ZibStack.NET.UI/README.md new file mode 100644 index 0000000..ddcda1a --- /dev/null +++ b/packages/ZibStack.NET.UI/README.md @@ -0,0 +1,380 @@ +# ZibStack.NET.UI + +Source generator for **UI form and table metadata**. Annotate your models and get compile-time form descriptors, table column definitions, and JSON schemas — no reflection, no runtime overhead. + +## Features + +- **Form metadata** — field types, labels, placeholders, groups, ordering, validation, conditional visibility +- **Table metadata** — columns, sorting, filtering, pagination, formatting +- **UI control hints** — `[Select]`, `[Slider]`, `[TextArea]`, `[DatePicker]`, `[PasswordInput]`, `[FilePicker]`, `[RichText]`, and more +- **Framework-agnostic** — generates neutral C# objects + JSON schema consumable by any UI (Blazor, React, Vue, Angular) +- **Cross-package integration** — auto-detects `ZibStack.NET.Validation` and `ZibStack.NET.Dto` attributes +- **Zero reflection** — everything generated at compile time + +## Quick Start + +```csharp +[Form] +[Table(DefaultSort = "Name")] +[FormGroup("basic", Label = "Basic Info", Order = 1)] +public partial class Player +{ + [FormIgnore] + public int Id { get; set; } + + [FormField(Label = "Player Name", Placeholder = "Enter name...", Group = "basic")] + [TableColumn(Sortable = true, Filterable = true)] + public required string Name { get; set; } + + [Slider(Min = 1, Max = 100)] + [TableColumn(Sortable = true)] + public int Level { get; set; } + + [Select(typeof(PlayerRole))] + [TableColumn(Sortable = true, Filterable = true)] + public PlayerRole Role { get; set; } + + [TextArea(Rows = 3)] + [TableIgnore] + public string? Biography { get; set; } + + [PasswordInput] + [TableIgnore] + public required string Password { get; set; } + + [FormConditional("Role", "Admin")] + public string? AdminNotes { get; set; } +} +``` + +The generator produces: + +```csharp +// On the partial class: +Player.GetFormDescriptor() // FormDescriptor object +Player.GetFormSchemaJson() // Compile-time baked JSON string +Player.GetTableDescriptor() // TableDescriptor object +Player.GetTableSchemaJson() // Compile-time baked JSON string +``` + +Serve via API: + +```csharp +app.MapGet("/api/forms/player", () => + Results.Content(Player.GetFormSchemaJson(), "application/json")); + +app.MapGet("/api/tables/player", () => + Results.Content(Player.GetTableSchemaJson(), "application/json")); +``` + +## Generated JSON + +### Form Schema + +```json +{ + "name": "Player", + "layout": "vertical", + "groups": [ + { "name": "basic", "label": "Basic Info", "order": 1 } + ], + "fields": [ + { + "name": "name", + "type": "string", + "uiHint": "text", + "label": "Player Name", + "placeholder": "Enter name...", + "group": "basic", + "order": 0, + "required": true, + "validation": { "required": true, "minLength": 2 } + }, + { + "name": "level", + "type": "integer", + "uiHint": "slider", + "group": "basic", + "order": 1, + "props": { "min": 1, "max": 100, "step": 1 } + }, + { + "name": "role", + "type": "enum", + "uiHint": "select", + "label": "Role", + "group": "basic", + "order": 2, + "options": [ + { "value": "Player", "label": "Player" }, + { "value": "Moderator", "label": "Moderator" }, + { "value": "Admin", "label": "Admin" } + ] + }, + { + "name": "biography", + "type": "string", + "uiHint": "textarea", + "order": 3, + "props": { "rows": 3 } + }, + { + "name": "adminNotes", + "type": "string", + "uiHint": "text", + "label": "Admin Notes", + "order": 5, + "conditional": { "field": "role", "operator": "equals", "value": "Admin" } + } + ] +} +``` + +### Table Schema + +```json +{ + "name": "Player", + "columns": [ + { "name": "name", "type": "string", "label": "Name", "sortable": true, "filterable": true }, + { "name": "level", "type": "integer", "label": "Level", "sortable": true, "filterable": false }, + { "name": "role", "type": "enum", "label": "Role", "sortable": true, "filterable": true, + "options": ["Player", "Moderator", "Admin"] } + ], + "pagination": { "defaultPageSize": 20, "pageSizes": [10, 20, 50, 100] }, + "defaultSort": { "column": "name", "direction": "asc" } +} +``` + +## Frontend Integration + +### Razor Pages (server-side) + +Use the `FormDescriptor` directly in `.cshtml` — no JSON, no JavaScript needed: + +```cshtml +@* Pages/Players/Create.cshtml *@ +@{ + var form = Player.GetFormDescriptor(); +} + +

@form.Name

+
+ @foreach (var group in form.Groups.OrderBy(g => g.Order)) + { +
+ @group.Label + @foreach (var field in form.Fields.Where(f => f.Group == group.Name).OrderBy(f => f.Order)) + { +
+ + @switch (field.UiHint) + { + case "text": + case "password": + + break; + case "select": + + break; + case "textarea": + + break; + case "slider": + + break; + case "checkbox": + + break; + case "datePicker": + + break; + } + @if (field.HelpText is not null) + { + @field.HelpText + } +
+ } +
+ } + +
+``` + +### Blazor + +```razor +@* Fetch schema once, render form dynamically *@ +@inject HttpClient Http + +@if (_schema is not null) +{ + @foreach (var field in _schema.Fields.OrderBy(f => f.Order)) + { +
+ + @switch (field.UiHint) + { + case "text": + + break; + case "select": + + break; + case "slider": + + break; + case "textarea": + + break; + + case "checkbox": + + break; + + case "select": + + break; + + case "radioGroup": +
+ @if (Field.Options is not null) + { + @foreach (var opt in Field.Options) + { + + } + } +
+ break; + + case "slider": +
+ + @(GetStringValue() ?? "0") +
+ break; + + case "datePicker": + + break; + + case "dateTimePicker": + + break; + + case "timePicker": + + break; + + case "filePicker": + + break; + + case "colorPicker": + + break; + + case "richText": + + break; + + default: + + break; + } + + @if (Field.HelpText is not null) + { + @Field.HelpText + } +
+} + +@code { + [Parameter, EditorRequired] + public FieldSchema Field { get; set; } = default!; + + [Parameter, EditorRequired] + public Dictionary Values { get; set; } = default!; + + [Parameter] + public EventCallback OnValueChanged { get; set; } + + private bool IsVisible + { + get + { + if (Field.Hidden == true) return false; + if (Field.Conditional is null) return true; + + Values.TryGetValue(Field.Conditional.Field, out var depVal); + var actual = depVal?.ToString() ?? ""; + var expected = Field.Conditional.Value; + + return Field.Conditional.Operator switch + { + "equals" => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase), + "notEquals" => !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase), + "contains" => actual.Contains(expected, StringComparison.OrdinalIgnoreCase), + _ => true + }; + } + } + + private string? GetStringValue() + { + Values.TryGetValue(Field.Name, out var val); + return val?.ToString(); + } + + private bool GetBoolValue() + { + Values.TryGetValue(Field.Name, out var val); + return val is true or "true" or "True"; + } + + private string GetProp(string key, string fallback) + { + if (Field.Props?.TryGetValue(key, out var v) == true) + return v?.ToString() ?? fallback; + return fallback; + } + + private async Task SetValue(object? value) + { + Values[Field.Name] = value; + if (OnValueChanged.HasDelegate) + await OnValueChanged.InvokeAsync(); + } + + private Task OnTextInput(ChangeEventArgs e) => SetValue(e.Value); + private Task OnSelectChange(ChangeEventArgs e) => SetValue(e.Value); + private Task OnCheckboxChange(ChangeEventArgs e) => SetValue(e.Value is true or "true" or "True"); +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicForm.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicForm.razor new file mode 100644 index 0000000..8910657 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicForm.razor @@ -0,0 +1,89 @@ +@using SampleBlazor.Models + +

@Schema.Name

+ +@foreach (var group in Schema.Groups.OrderBy(g => g.Order)) +{ +
+ @(group.Label ?? group.Name) + + @foreach (var field in GetFieldsForGroup(group.Name)) + { + + } +
+} + +@* Fields without a group *@ +@foreach (var field in GetUngroupedFields()) +{ + +} + + + +@if (_submitted) +{ +

Submitted values:

+
@FormatValues()
+} + +@code { + [Parameter, EditorRequired] + public FormSchema Schema { get; set; } = default!; + + [Parameter] + public string Mode { get; set; } = "create"; + + [Parameter] + public Dictionary? InitialValues { get; set; } + + [Parameter] + public EventCallback> OnSubmit { get; set; } + + private Dictionary Values { get; set; } = new(); + private bool _submitted; + + protected override void OnInitialized() + { + foreach (var field in Schema.Fields) + Values[field.Name] = InitialValues?.GetValueOrDefault(field.Name); + } + + private IEnumerable GetFieldsForGroup(string group) + { + return Schema.Fields + .Where(f => f.Group == group) + .Where(f => FilterByMode(f)) + .OrderBy(f => f.Order); + } + + private IEnumerable GetUngroupedFields() + { + return Schema.Fields + .Where(f => f.Group is null) + .Where(f => FilterByMode(f)) + .OrderBy(f => f.Order); + } + + private bool FilterByMode(FieldSchema field) + { + if (Mode == "create" && field.UpdateOnly == true) return false; + if (Mode == "edit" && field.CreateOnly == true) return false; + return true; + } + + private async Task HandleSubmit() + { + _submitted = true; + if (OnSubmit.HasDelegate) + await OnSubmit.InvokeAsync(Values); + } + + private string FormatValues() + { + return string.Join("\n", Values + .Where(kv => kv.Value is not null && kv.Value.ToString() != "") + .Select(kv => $"{kv.Key}: {kv.Value}")); + } +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/_Imports.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/_Imports.razor new file mode 100644 index 0000000..f4554bc --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Forms +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using SampleBlazor.Components.Shared diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/FormSchema.cs b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/FormSchema.cs new file mode 100644 index 0000000..abea742 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/FormSchema.cs @@ -0,0 +1,60 @@ +namespace SampleBlazor.Models; + +/// +/// Deserialization models for the JSON schema produced by ZibStack.NET.UI. +/// These mirror the JSON structure from GetFormSchemaJson() / GetTableSchemaJson(). +/// +public record FormSchema( + string Name, + string Layout, + List Groups, + List Fields); + +public record GroupSchema(string Name, string? Label, int Order); + +public record FieldSchema( + string Name, + string Type, + string UiHint, + string? Label, + string? Placeholder, + string? HelpText, + string? Group, + int Order, + bool? Required, + bool? Hidden, + bool? ReadOnly, + bool? Disabled, + bool? CreateOnly, + bool? UpdateOnly, + bool? Nullable, + List? Options, + ConditionalSchema? Conditional, + Dictionary? Validation, + Dictionary? Props); + +public record OptionSchema(string Value, string Label); + +public record ConditionalSchema(string Field, string Operator, string Value); + +public record TableSchema( + string Name, + List Columns, + PaginationSchema Pagination, + SortSchema? DefaultSort); + +public record ColumnSchema( + string Name, + string Type, + string? Label, + bool Sortable, + bool Filterable, + string? Format, + int Order, + bool? Visible, + string? Width, + List? Options); + +public record PaginationSchema(int DefaultPageSize, List PageSizes); + +public record SortSchema(string Column, string Direction); diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Player.cs b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Player.cs new file mode 100644 index 0000000..6590faa --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Player.cs @@ -0,0 +1,58 @@ +using ZibStack.NET.UI; + +namespace SampleBlazor.Models; + +public enum PlayerRole +{ + Player, + Moderator, + Admin +} + +[Form] +[Table(DefaultSort = "Name", DefaultPageSize = 20)] +[FormGroup("basic", Label = "Basic Info", Order = 1)] +[FormGroup("contact", Label = "Contact", Order = 2)] +public partial class Player +{ + [FormIgnore] + [TableColumn(IsVisible = false)] + public int Id { get; set; } + + [FormField(Label = "Player Name", Placeholder = "Enter name...", Group = "basic")] + [TableColumn(Sortable = true, Filterable = true)] + public required string Name { get; set; } + + [Slider(Min = 1, Max = 100)] + [FormField(Group = "basic")] + [TableColumn(Sortable = true)] + public int Level { get; set; } + + [Select(typeof(PlayerRole))] + [FormField(Group = "basic", Label = "Role")] + [TableColumn(Sortable = true, Filterable = true)] + public PlayerRole Role { get; set; } + + [TextArea(Rows = 3)] + [FormField(Group = "contact", HelpText = "Tell us about yourself")] + [TableIgnore] + public string? Biography { get; set; } + + [PasswordInput] + [TableIgnore] + public required string Password { get; set; } + + [FormConditional("Role", "Admin")] + [FormField(Label = "Admin Notes")] + [TableIgnore] + public string? AdminNotes { get; set; } + + [DatePicker] + [FormField(Group = "basic")] + [TableColumn(Sortable = true, Format = "yyyy-MM-dd")] + public DateTime CreatedAt { get; set; } + + [FormField(Group = "contact", Label = "Email Address")] + [TableColumn(Filterable = true)] + public string? Email { get; set; } +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Voivodeship.cs b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Voivodeship.cs new file mode 100644 index 0000000..fdf3aad --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Voivodeship.cs @@ -0,0 +1,86 @@ +using ZibStack.NET.UI; + +namespace SampleBlazor.Models; + +[Table(DefaultSort = "Name")] +public partial class CountyView +{ + [TableColumn(IsVisible = false)] + public int Id { get; set; } + public int VoivodeshipId { get; set; } + [TableColumn(Sortable = true, Filterable = true)] + public string Name { get; set; } = ""; + [TableColumn(Sortable = true)] + public int Population { get; set; } +} + +[Table(DefaultSort = "Code")] +public partial class PostalCodeView +{ + [TableColumn(IsVisible = false)] + public int Id { get; set; } + public int VoivodeshipId { get; set; } + [TableColumn(Sortable = true, Filterable = true)] + public string Code { get; set; } = ""; + [TableColumn(Sortable = true)] + public string City { get; set; } = ""; +} + +[Form] +[Table(DefaultSort = "Name", DefaultPageSize = 50)] +[Permission("voivodeship.read")] +[ColumnPermission("Budget", "finance.read")] +[DataFilter("VoivodeshipId")] +[ChildTable(typeof(CountyView), ForeignKey = "VoivodeshipId", Label = "Powiaty")] +[ChildTable(typeof(PostalCodeView), ForeignKey = "VoivodeshipId", Label = "Kody pocztowe")] +[RowAction("showDetails", Label = "Szczegóły", Endpoint = "/api/voivodeships/{id}")] +[RowAction("generateReport", Label = "Raport", Icon = "file", + Endpoint = "/api/voivodeships/{id}/report", Method = "POST", + Confirmation = "Wygenerować raport?")] +[ToolbarAction("export", Label = "Eksport do Excel", Icon = "download", + Endpoint = "/api/voivodeships/export", Method = "GET", + SelectionMode = "multiple")] +[ToolbarAction("recalculate", Label = "Przelicz salda", + Endpoint = "/api/voivodeships/recalculate", Method = "POST", + Confirmation = "Przeliczyć salda dla wszystkich województw?", + Permission = "finance.write")] +[FormGroup("basic", Label = "Dane podstawowe", Order = 1)] +[FormGroup("finance", Label = "Finanse", Order = 2)] +public partial class VoivodeshipView +{ + [FormIgnore][TableColumn(IsVisible = false)] + public int Id { get; set; } + + [FormField(Label = "Nazwa", Placeholder = "Nazwa województwa", Group = "basic")] + [TableColumn(Sortable = true, Filterable = true)] + public string Name { get; set; } = ""; + + [FormField(Label = "Kod", Group = "basic")] + [TableColumn(Sortable = true, Filterable = true)] + public string Code { get; set; } = ""; + + [FormField(Label = "Stolica", Group = "basic")] + [TableColumn(Sortable = true)] + public string Capital { get; set; } = ""; + + [FormIgnore] + [TableColumn(Sortable = true, Label = "Budżet")] + [Computed] + [ColumnStyle(When = "value < 0", Severity = "danger")] + [ColumnStyle(When = "value >= 0", Severity = "success")] + public decimal Budget { get; set; } + + [FormIgnore] + [TableColumn(Sortable = true, Label = "Liczba powiatów")] + [Computed] + public int CountyCount { get; set; } + + [FormField(Label = "Populacja", Group = "basic")] + [TableColumn(Sortable = true, Format = "N0")] + public int Population { get; set; } + + [FormField(Label = "Notatki", Group = "finance")] + [TextArea(Rows = 3)] + [TableIgnore] + public string? Notes { get; set; } +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Program.cs b/packages/ZibStack.NET.UI/sample/SampleBlazor/Program.cs new file mode 100644 index 0000000..228e3ae --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Program.cs @@ -0,0 +1,10 @@ +using SampleBlazor.Components; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +var app = builder.Build(); +app.UseStaticFiles(); +app.UseAntiforgery(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); +app.Run(); diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/SampleBlazor.csproj b/packages/ZibStack.NET.UI/sample/SampleBlazor/SampleBlazor.csproj new file mode 100644 index 0000000..f752545 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/SampleBlazor.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + latest + enable + enable + true + + + + + + + diff --git a/packages/ZibStack.NET.UI/sample/react-app/index.html b/packages/ZibStack.NET.UI/sample/react-app/index.html new file mode 100644 index 0000000..fee4156 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/index.html @@ -0,0 +1,12 @@ + + + + + + ZibStack.NET.UI — React Sample + + +
+ + + diff --git a/packages/ZibStack.NET.UI/sample/react-app/package.json b/packages/ZibStack.NET.UI/sample/react-app/package.json new file mode 100644 index 0000000..2e8a373 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "zibstack-ui-react-sample", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx b/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx new file mode 100644 index 0000000..c980a4c --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx @@ -0,0 +1,85 @@ +import { DynamicForm } from './components/DynamicForm'; +import { DynamicTable } from './components/DynamicTable'; +import { ErpTable } from './components/ErpTable'; + +/** + * Sample app demonstrating ZibStack.NET.UI with React. + * + * Prerequisites: + * 1. Run the SampleApi backend: `dotnet run --project ../SampleApi` + * 2. The API serves form/table schemas at /api/forms/player and /api/tables/player + */ + +const samplePlayers = [ + { id: 1, name: 'Alice', level: 42, role: 'Admin', createdAt: '2024-01-15', email: 'alice@example.com' }, + { id: 2, name: 'Bob', level: 17, role: 'Player', createdAt: '2024-03-22', email: 'bob@example.com' }, + { id: 3, name: 'Charlie', level: 88, role: 'Moderator', createdAt: '2024-06-01', email: 'charlie@example.com' }, +]; + +const sampleVoivodeships = [ + { id: 1, name: 'Wielkopolskie', code: 'WP', capital: 'Poznań', budget: 1250000, countyCount: 35, population: 3498733 }, + { id: 2, name: 'Mazowieckie', code: 'MZ', capital: 'Warszawa', budget: -500000, countyCount: 42, population: 5411446 }, + { id: 3, name: 'Małopolskie', code: 'MA', capital: 'Kraków', budget: 750000, countyCount: 22, population: 3400577 }, +]; + +export default function App() { + return ( +
+

ZibStack.NET.UI — React Sample

+ +
+

Dynamic Form (Create Mode)

+

+ This form is rendered from Player.GetFormSchemaJson(). + Select "Admin" as the role to see conditional field visibility in action. +

+ { + console.log('Form submitted:', data); + alert('Submitted! Check console for values.'); + }} + /> +
+ +
+ +
+

Dynamic Table

+

+ This table is rendered from Player.GetTableSchemaJson(). + Click column headers to sort. Use filter inputs to filter. +

+ console.log('Table query changed:', q)} + /> +
+ +
+ +
+

ERP Table — Voivodeships

+

+ Full ERP-style table from VoivodeshipView.GetTableSchemaJson(). + Features: toolbar actions (Eksport, Przelicz salda),{' '} + row actions (Szczegóły, Raport),{' '} + drill-down (Powiaty →, Kody pocztowe →),{' '} + computed columns with conditional styling (Budget: red when negative, green when positive),{' '} + permission metadata, and row selection for multi-select actions. +

+ + console.log(`Navigate to ${target} where ${fk}=${parentId}`) + } + /> +
+
+ ); +} diff --git a/packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicField.tsx b/packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicField.tsx new file mode 100644 index 0000000..31e78e9 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicField.tsx @@ -0,0 +1,178 @@ +import { Control, Controller } from 'react-hook-form'; +import { FieldSchema } from '../hooks/useFormSchema'; + +interface Props { + field: FieldSchema; + control: Control; + values: Record; +} + +/** + * Renders a single form field based on the ZibStack.NET.UI schema. + * Handles all UI hints, conditional visibility, and validation rules. + */ +export function DynamicField({ field, control, values }: Props) { + // ── Conditional visibility ─────────────────────────────────────────── + if (field.conditional) { + const depValue = String(values[field.conditional.field] ?? ''); + const expected = field.conditional.value; + + switch (field.conditional.operator) { + case 'equals': if (depValue !== expected) return null; break; + case 'notEquals': if (depValue === expected) return null; break; + case 'contains': if (!depValue.includes(expected)) return null; break; + case 'greaterThan': if (!(Number(depValue) > Number(expected))) return null; break; + case 'lessThan': if (!(Number(depValue) < Number(expected))) return null; break; + } + } + + if (field.hidden) return null; + + // ── Validation rules from schema → react-hook-form ─────────────────── + const rules: Record = {}; + if (field.required) + rules.required = `${field.label ?? field.name} is required`; + if (field.validation?.minLength) + rules.minLength = { value: Number(field.validation.minLength), message: `Min ${field.validation.minLength} characters` }; + if (field.validation?.maxLength) + rules.maxLength = { value: Number(field.validation.maxLength), message: `Max ${field.validation.maxLength} characters` }; + if (field.validation?.min) + rules.min = { value: Number(field.validation.min), message: `Minimum: ${field.validation.min}` }; + if (field.validation?.max) + rules.max = { value: Number(field.validation.max), message: `Maximum: ${field.validation.max}` }; + if (field.validation?.pattern) + rules.pattern = { value: new RegExp(field.validation.pattern), message: 'Invalid format' }; + if (field.validation?.email) + rules.pattern = { value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, message: 'Invalid email address' }; + if (field.validation?.url) + rules.pattern = { value: /^https?:\/\/.+/, message: 'Invalid URL' }; + + return ( +
+ + + ( + <> + {renderInput(field, f)} + {field.helpText && ( + + {field.helpText} + + )} + {error && ( + + {error.message} + + )} + + )} + /> +
+ ); +} + +function renderInput(schema: FieldSchema, field: any) { + const props = schema.props ?? {}; + const baseStyle = { width: '100%', padding: '0.5rem', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' as const }; + + switch (schema.uiHint) { + case 'text': + return ( + + ); + + case 'password': + return ( + + ); + + case 'number': + return ( + field.onChange(e.target.value === '' ? null : +e.target.value)} /> + ); + + case 'textarea': + return ( +