From 83e061d93cf64d04b7227f3df76de84eeadcbc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 19:45:51 +0200 Subject: [PATCH 1/6] Add ZibStack.NET.UI source generator package with Blazor and React samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source generator for UI form and table metadata. Annotate models with [Form], [Table], and UI control hints ([Select], [Slider], [TextArea], etc.) to get compile-time form descriptors, table column definitions, and JSON schemas — no reflection, no runtime overhead. Includes: - 27 attributes (form metadata, UI controls, table columns, conditionals) - Cross-package integration with ZibStack.NET.Validation and ZibStack.NET.Dto - Blazor sample with DynamicForm/DynamicField components - React sample with react-hook-form integration and DynamicTable - 34 unit tests --- ZibStack.NET.slnx | 11 + packages/ZibStack.NET.UI/README.md | 123 ++++ .../SampleApi/Controllers/FormsController.cs | 17 + .../sample/SampleApi/Models/Player.cs | 58 ++ .../sample/SampleApi/Program.cs | 7 + .../sample/SampleApi/SampleApi.csproj | 17 + .../sample/SampleBlazor/Components/App.razor | 36 + .../SampleBlazor/Components/Pages/Home.razor | 23 + .../Components/Pages/JsonPage.razor | 26 + .../SampleBlazor/Components/Routes.razor | 5 + .../Components/Shared/DynamicField.razor | 197 ++++++ .../Components/Shared/DynamicForm.razor | 89 +++ .../SampleBlazor/Components/_Imports.razor | 5 + .../sample/SampleBlazor/Models/FormSchema.cs | 60 ++ .../sample/SampleBlazor/Models/Player.cs | 58 ++ .../sample/SampleBlazor/Program.cs | 10 + .../sample/SampleBlazor/SampleBlazor.csproj | 17 + .../sample/react-app/index.html | 12 + .../sample/react-app/package.json | 22 + .../sample/react-app/src/App.tsx | 57 ++ .../react-app/src/components/DynamicField.tsx | 178 +++++ .../react-app/src/components/DynamicForm.tsx | 75 +++ .../react-app/src/components/DynamicTable.tsx | 178 +++++ .../react-app/src/hooks/useFormSchema.ts | 102 +++ .../sample/react-app/src/main.tsx | 9 + .../sample/react-app/tsconfig.json | 21 + .../sample/react-app/vite.config.ts | 12 + .../ZibStack.NET.UI/Models/FormClassInfo.cs | 35 + .../ZibStack.NET.UI/Models/FormFieldInfo.cs | 78 +++ .../ZibStack.NET.UI/Models/FormGroupInfo.cs | 15 + .../ZibStack.NET.UI/Models/TableClassInfo.cs | 66 ++ .../UiGenerator.AttributeSources.cs | 338 ++++++++++ .../ZibStack.NET.UI/UiGenerator.Extraction.cs | 617 ++++++++++++++++++ .../ZibStack.NET.UI/UiGenerator.Generation.cs | 161 +++++ .../UiGenerator.JsonGeneration.cs | 262 ++++++++ .../UiGenerator.RuntimeTypes.cs | 242 +++++++ .../UiGenerator.TableGeneration.cs | 94 +++ .../src/ZibStack.NET.UI/UiGenerator.cs | 115 ++++ .../ZibStack.NET.UI/ZibStack.NET.UI.csproj | 35 + .../tests/ZibStack.NET.UI.Tests/FormTests.cs | 348 ++++++++++ .../tests/ZibStack.NET.UI.Tests/Models.cs | 119 ++++ .../ZibStack.NET.UI.Tests.csproj | 24 + 42 files changed, 3974 insertions(+) create mode 100644 packages/ZibStack.NET.UI/README.md create mode 100644 packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleApi/Models/Player.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleApi/Program.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleApi/SampleApi.csproj create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/Home.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/JsonPage.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Routes.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicField.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicForm.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/_Imports.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Models/FormSchema.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Player.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Program.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/SampleBlazor.csproj create mode 100644 packages/ZibStack.NET.UI/sample/react-app/index.html create mode 100644 packages/ZibStack.NET.UI/sample/react-app/package.json create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/App.tsx create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicField.tsx create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicForm.tsx create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/components/DynamicTable.tsx create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/hooks/useFormSchema.ts create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/main.tsx create mode 100644 packages/ZibStack.NET.UI/sample/react-app/tsconfig.json create mode 100644 packages/ZibStack.NET.UI/sample/react-app/vite.config.ts create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/FormClassInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/FormFieldInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/FormGroupInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/TableClassInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.AttributeSources.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Extraction.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Generation.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.JsonGeneration.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.RuntimeTypes.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.TableGeneration.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/ZibStack.NET.UI.csproj create mode 100644 packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/FormTests.cs create mode 100644 packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/Models.cs create mode 100644 packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/ZibStack.NET.UI.Tests.csproj 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..2cae7f7 --- /dev/null +++ b/packages/ZibStack.NET.UI/README.md @@ -0,0 +1,123 @@ +# 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")); +``` + +## Form Attributes + +| Attribute | Target | Purpose | +|-----------|--------|---------| +| `[Form]` | Class | Mark for form generation | +| `[FormGroup("name")]` | Class | Define field group (AllowMultiple) | +| `[FormField]` | Property | Label, Placeholder, HelpText, Order, Group | +| `[FormIgnore]` | Property | Exclude from form | +| `[FormHidden]` | Property | In data but not rendered | +| `[FormOrder(n)]` | Property | Explicit ordering | +| `[FormReadOnly]` | Property | Read-only field | +| `[FormDisabled]` | Property | Disabled field | +| `[FormSection("group")]` | Property | Assign to group | +| `[FormConditional("field", "value")]` | Property | Conditional visibility | + +## UI Control Hints + +| Attribute | Control | +|-----------|---------| +| `[TextArea(Rows = 3)]` | Multi-line text | +| `[Select(typeof(Enum))]` | Dropdown | +| `[RadioGroup(typeof(Enum))]` | Radio buttons | +| `[Checkbox]` | Toggle | +| `[DatePicker]` | Date selector | +| `[TimePicker]` | Time selector | +| `[DateTimePicker]` | Date + time | +| `[FilePicker(Accept = "image/*")]` | File upload | +| `[ColorPicker]` | Color selector | +| `[RichText]` | Rich text editor | +| `[Slider(Min = 0, Max = 100)]` | Range slider | +| `[PasswordInput]` | Masked input | + +## Table Attributes + +| Attribute | Target | Purpose | +|-----------|--------|---------| +| `[Table]` | Class | Mark for table generation (DefaultPageSize, PageSizes, DefaultSort) | +| `[TableColumn]` | Property | Sortable, Filterable, Format, Width, IsVisible | +| `[TableIgnore]` | Property | Exclude from table | + +## Default Behavior + +- All public properties included unless `[FormIgnore]` / `[TableIgnore]` +- UI hint auto-detected from C# type: `string` → text, `bool` → checkbox, `enum` → select, `DateTime` → datePicker +- Labels humanized from property names: `FirstName` → "First Name" +- A class can have both `[Form]` and `[Table]` + +## Cross-Package Integration + +When `ZibStack.NET.Validation` is referenced, validation attributes (`[Required]`, `[Email]`, `[Range]`, etc.) are automatically included in form metadata. + +When `ZibStack.NET.Dto` is referenced, `[CreateOnly]` and `[UpdateOnly]` flags appear in form field descriptors. + +No project-level dependencies — detection is by attribute FQN at compile time. diff --git a/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs b/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs new file mode 100644 index 0000000..884f6d4 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using SampleApi.Models; + +namespace SampleApi.Controllers; + +[ApiController] +[Route("api")] +public class FormsController : ControllerBase +{ + [HttpGet("forms/player")] + public IActionResult GetPlayerForm() + => Content(Player.GetFormSchemaJson(), "application/json"); + + [HttpGet("tables/player")] + public IActionResult GetPlayerTable() + => Content(Player.GetTableSchemaJson(), "application/json"); +} diff --git a/packages/ZibStack.NET.UI/sample/SampleApi/Models/Player.cs b/packages/ZibStack.NET.UI/sample/SampleApi/Models/Player.cs new file mode 100644 index 0000000..e9eaae1 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleApi/Models/Player.cs @@ -0,0 +1,58 @@ +using ZibStack.NET.UI; + +namespace SampleApi.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/SampleApi/Program.cs b/packages/ZibStack.NET.UI/sample/SampleApi/Program.cs new file mode 100644 index 0000000..9775cda --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleApi/Program.cs @@ -0,0 +1,7 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +var app = builder.Build(); +app.MapControllers(); +app.Run(); diff --git a/packages/ZibStack.NET.UI/sample/SampleApi/SampleApi.csproj b/packages/ZibStack.NET.UI/sample/SampleApi/SampleApi.csproj new file mode 100644 index 0000000..f752545 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleApi/SampleApi.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + latest + enable + enable + true + + + + + + + diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor new file mode 100644 index 0000000..c040752 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor @@ -0,0 +1,36 @@ + + + + + + ZibStack.NET.UI — Blazor Sample + + + + + + + + + + diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/Home.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/Home.razor new file mode 100644 index 0000000..9cd1b6b --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/Home.razor @@ -0,0 +1,23 @@ +@page "/" +@using SampleBlazor.Models +@using System.Text.Json + +

ZibStack.NET.UI — Blazor Dynamic Form Demo

+

This form is rendered entirely from the compile-time generated FormDescriptor.

+ + + +@code { + private FormSchema _schema = default!; + + protected override void OnInitialized() + { + // Deserialize directly from the compile-time baked JSON. + // In a real app you'd fetch this from an API endpoint. + var json = Player.GetFormSchemaJson(); + _schema = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + })!; + } +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/JsonPage.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/JsonPage.razor new file mode 100644 index 0000000..6e3b174 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/JsonPage.razor @@ -0,0 +1,26 @@ +@page "/json" +@using SampleBlazor.Models +@using System.Text.Json + +

Generated JSON Schemas

+ +

Form Schema

+
@_formJson
+ +

Table Schema

+
@_tableJson
+ +@code { + private string _formJson = ""; + private string _tableJson = ""; + + protected override void OnInitialized() + { + // Pretty-print the compile-time baked JSON + var formDoc = JsonDocument.Parse(Player.GetFormSchemaJson()); + _formJson = JsonSerializer.Serialize(formDoc, new JsonSerializerOptions { WriteIndented = true }); + + var tableDoc = JsonDocument.Parse(Player.GetTableSchemaJson()); + _tableJson = JsonSerializer.Serialize(tableDoc, new JsonSerializerOptions { WriteIndented = true }); + } +} diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Routes.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Routes.razor new file mode 100644 index 0000000..06570ce --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicField.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicField.razor new file mode 100644 index 0000000..75655b1 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Shared/DynamicField.razor @@ -0,0 +1,197 @@ +@using SampleBlazor.Models + +@if (IsVisible) +{ +
+ + + @switch (Field.UiHint) + { + case "text": + + break; + + case "password": + + break; + + case "number": + + 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/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..c308f87 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx @@ -0,0 +1,57 @@ +import { DynamicForm } from './components/DynamicForm'; +import { DynamicTable } from './components/DynamicTable'; + +/** + * 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 + */ + +// Example data for the table demo +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' }, +]; + +export default function App() { + return ( +
+

ZibStack.NET.UI — React Sample

+ +
+

Dynamic Form (Create Mode)

+

+ This form is rendered from the JSON schema served by 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)} + /> +
+
+ ); +} 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 ( + + break; + case "slider": + + break; + case "checkbox": + + break; + case "datePicker": + + break; + } + @if (field.HelpText is not null) + { + @field.HelpText + } + + } + + } + + +``` + ### Blazor ```razor From e932aa08bced3fcdabb4ab1e83eafb28551e0b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 20:46:55 +0200 Subject: [PATCH 4/6] Add ERP features: child tables, row/toolbar actions, permissions, computed columns, column styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New attributes: - [ChildTable] — hierarchical drill-down (parent → child tables) - [RowAction] — per-row action buttons with endpoint, confirmation, permission - [ToolbarAction] — global actions (payroll, export) with selection mode - [Permission], [ColumnPermission], [DataFilter] — authorization metadata - [Computed] — marks virtual/calculated columns - [ColumnStyle] — conditional styling with semantic severity (danger, success, etc.) All features emit to both C# descriptors and JSON schema. 12 new tests (46 total), all passing. --- .../src/ZibStack.NET.UI/Models/ActionInfo.cs | 47 ++++++ .../ZibStack.NET.UI/Models/ChildTableInfo.cs | 17 +++ .../ZibStack.NET.UI/Models/PermissionInfo.cs | 24 +++ .../ZibStack.NET.UI/Models/TableClassInfo.cs | 6 + .../UiGenerator.AttributeSources.cs | 137 ++++++++++++++++++ .../ZibStack.NET.UI/UiGenerator.Extraction.cs | 98 ++++++++++++- .../UiGenerator.JsonGeneration.cs | 92 ++++++++++++ .../UiGenerator.RuntimeTypes.cs | 100 ++++++++++++- .../UiGenerator.TableGeneration.cs | 104 ++++++++++++- .../src/ZibStack.NET.UI/UiGenerator.cs | 22 +++ .../tests/ZibStack.NET.UI.Tests/FormTests.cs | 133 +++++++++++++++++ .../tests/ZibStack.NET.UI.Tests/Models.cs | 44 ++++++ 12 files changed, 819 insertions(+), 5 deletions(-) create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ActionInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ChildTableInfo.cs create mode 100644 packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/PermissionInfo.cs diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ActionInfo.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ActionInfo.cs new file mode 100644 index 0000000..387a2ab --- /dev/null +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ActionInfo.cs @@ -0,0 +1,47 @@ +namespace ZibStack.NET.UI; + +internal sealed class RowActionInfo +{ + public string Name { get; } + public string Label { get; } + public string? Icon { get; } + public string Endpoint { get; } + public string Method { get; } + public string? Confirmation { get; } + public string? Permission { get; } + + public RowActionInfo(string name, string label, string? icon, string endpoint, string method, string? confirmation, string? permission) + { + Name = name; + Label = label; + Icon = icon; + Endpoint = endpoint; + Method = method; + Confirmation = confirmation; + Permission = permission; + } +} + +internal sealed class ToolbarActionInfo +{ + public string Name { get; } + public string Label { get; } + public string? Icon { get; } + public string Endpoint { get; } + public string Method { get; } + public string? Confirmation { get; } + public string? Permission { get; } + public string SelectionMode { get; } + + public ToolbarActionInfo(string name, string label, string? icon, string endpoint, string method, string? confirmation, string? permission, string selectionMode) + { + Name = name; + Label = label; + Icon = icon; + Endpoint = endpoint; + Method = method; + Confirmation = confirmation; + Permission = permission; + SelectionMode = selectionMode; + } +} diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ChildTableInfo.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ChildTableInfo.cs new file mode 100644 index 0000000..51a8911 --- /dev/null +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/ChildTableInfo.cs @@ -0,0 +1,17 @@ +namespace ZibStack.NET.UI; + +internal sealed class ChildTableInfo +{ + public string TargetTypeName { get; } + public string ForeignKey { get; } + public string Label { get; } + public string? SchemaUrl { get; } + + public ChildTableInfo(string targetTypeName, string foreignKey, string label, string? schemaUrl) + { + TargetTypeName = targetTypeName; + ForeignKey = foreignKey; + Label = label; + SchemaUrl = schemaUrl; + } +} diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/PermissionInfo.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/PermissionInfo.cs new file mode 100644 index 0000000..59bd53d --- /dev/null +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/PermissionInfo.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace ZibStack.NET.UI; + +internal sealed class PermissionInfo +{ + public string? ViewPermission { get; set; } + public Dictionary ColumnPermissions { get; } = new Dictionary(); + public List DataFilters { get; } = new List(); + + public bool HasAny => ViewPermission != null || ColumnPermissions.Count > 0 || DataFilters.Count > 0; +} + +internal sealed class ColumnStyleInfo +{ + public string When { get; } + public string Severity { get; } + + public ColumnStyleInfo(string when, string severity) + { + When = when; + Severity = severity; + } +} diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/TableClassInfo.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/TableClassInfo.cs index eca9d0d..f1593cb 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/TableClassInfo.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/Models/TableClassInfo.cs @@ -14,6 +14,10 @@ internal sealed class TableClassInfo public int[] PageSizes { get; } public string? DefaultSort { get; } public string DefaultSortDirection { get; } + public List Children { get; } = new List(); + public List RowActions { get; } = new List(); + public List ToolbarActions { get; } = new List(); + public PermissionInfo? Permissions { get; set; } public TableClassInfo( string className, @@ -55,6 +59,8 @@ internal sealed class TableColumnInfo public string? Width { get; set; } public bool IsEnum { get; set; } public List EnumValues { get; } = new List(); + public bool IsComputed { get; set; } + public List Styles { get; } = new List(); public TableColumnInfo(string propertyName, string jsonName, string typeName, string columnType) { diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.AttributeSources.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.AttributeSources.cs index 6d5e8cc..6798c51 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.AttributeSources.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.AttributeSources.cs @@ -334,5 +334,142 @@ namespace ZibStack.NET.UI [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] internal sealed class TableIgnoreAttribute : System.Attribute { } } +"; + + // ─── ERP: Child tables (drill-down) ────────────────────────────────── + + private const string ChildTableAttributeSource = @"// +#nullable enable +namespace ZibStack.NET.UI +{ + /// Defines a child table relationship for hierarchical drill-down. + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + internal sealed class ChildTableAttribute : System.Attribute + { + public System.Type TargetType { get; } + public string ForeignKey { get; set; } = """"; + public string Label { get; set; } = """"; + public string? SchemaUrl { get; set; } + public ChildTableAttribute(System.Type targetType) => TargetType = targetType; + } +} +"; + + // ─── ERP: Row actions (wstawki) ────────────────────────────────────── + + private const string RowActionAttributeSource = @"// +#nullable enable +namespace ZibStack.NET.UI +{ + /// Defines a per-row action button (e.g. ""Show postal codes""). + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + internal sealed class RowActionAttribute : System.Attribute + { + public string Name { get; } + public string Label { get; set; } = """"; + public string? Icon { get; set; } + public string Endpoint { get; set; } = """"; + public string Method { get; set; } = ""GET""; + public string? Confirmation { get; set; } + public string? Permission { get; set; } + public RowActionAttribute(string name) => Name = name; + } +} +"; + + // ─── ERP: Toolbar actions (narzędzia) ──────────────────────────────── + + private const string ToolbarActionAttributeSource = @"// +#nullable enable +namespace ZibStack.NET.UI +{ + /// Defines a global toolbar action (e.g. ""Calculate payroll"", ""Export""). + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + internal sealed class ToolbarActionAttribute : System.Attribute + { + public string Name { get; } + public string Label { get; set; } = """"; + public string? Icon { get; set; } + public string Endpoint { get; set; } = """"; + public string Method { get; set; } = ""POST""; + public string? Confirmation { get; set; } + public string? Permission { get; set; } + /// Selection mode: none, single, or multiple. + public string SelectionMode { get; set; } = ""none""; + public ToolbarActionAttribute(string name) => Name = name; + } +} +"; + + // ─── ERP: Permissions ──────────────────────────────────────────────── + + private const string PermissionAttributeSource = @"// +namespace ZibStack.NET.UI +{ + /// Required permission to view this form/table. + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false)] + internal sealed class PermissionAttribute : System.Attribute + { + public string Name { get; } + public PermissionAttribute(string name) => Name = name; + } +} +"; + + private const string ColumnPermissionAttributeSource = @"// +namespace ZibStack.NET.UI +{ + /// Required permission to see a specific column. + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + internal sealed class ColumnPermissionAttribute : System.Attribute + { + public string Column { get; } + public string Permission { get; } + public ColumnPermissionAttribute(string column, string permission) + { + Column = column; + Permission = permission; + } + } +} +"; + + private const string DataFilterAttributeSource = @"// +namespace ZibStack.NET.UI +{ + /// Marks a property as a data filter scope (e.g. user can only see their own VoivodeshipId). + [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] + internal sealed class DataFilterAttribute : System.Attribute + { + public string PropertyName { get; } + public DataFilterAttribute(string propertyName) => PropertyName = propertyName; + } +} +"; + + // ─── ERP: Computed columns ─────────────────────────────────────────── + + private const string ComputedAttributeSource = @"// +namespace ZibStack.NET.UI +{ + /// Marks a column as computed/virtual (not directly from DB). + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class ComputedAttribute : System.Attribute { } +} +"; + + // ─── ERP: Column styling ───────────────────────────────────────────── + + private const string ColumnStyleAttributeSource = @"// +namespace ZibStack.NET.UI +{ + /// Conditional styling for a column. Severity: danger, warning, success, info, muted. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true, AllowMultiple = true)] + internal sealed class ColumnStyleAttribute : System.Attribute + { + public string When { get; set; } = """"; + public string Severity { get; set; } = """"; + } +} "; } diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Extraction.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Extraction.cs index 223452b..ef32860 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Extraction.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.Extraction.cs @@ -603,15 +603,111 @@ private static void ExtractDtoAttributes(IPropertySymbol prop, FormFieldInfo fie col.Width = GetNamedArgString(colAttr, "Width"); } + // [Computed] + if (HasAttribute(prop, ComputedAttributeFqn)) + col.IsComputed = true; + + // [ColumnStyle] (AllowMultiple) + foreach (var styleAttr in prop.GetAttributes()) + { + if (styleAttr.AttributeClass?.ToDisplayString() == ColumnStyleAttributeFqn) + { + var when = GetNamedArgString(styleAttr, "When"); + var severity = GetNamedArgString(styleAttr, "Severity"); + if (when != null && severity != null) + col.Styles.Add(new ColumnStyleInfo(when, severity)); + } + } + columns.Add(col); } - return new TableClassInfo(symbol.Name, ns, hintName, tableName, isRecord, columns, + var result = new TableClassInfo(symbol.Name, ns, hintName, tableName, isRecord, columns, defaultPageSize, pageSizes, defaultSort, defaultSortDirection.ToLowerInvariant()); + + // ERP: class-level attributes + ExtractErpClassAttributes(symbol, result); + + return result; } catch { return null; } } + + private static void ExtractErpClassAttributes(INamedTypeSymbol symbol, TableClassInfo info) + { + var permissions = new PermissionInfo(); + + foreach (var attr in symbol.GetAttributes()) + { + var fqn = attr.AttributeClass?.ToDisplayString(); + if (fqn == null) continue; + + switch (fqn) + { + case ChildTableAttributeFqn: + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is INamedTypeSymbol targetType) + { + var foreignKey = GetNamedArgString(attr, "ForeignKey") ?? ""; + var label = GetNamedArgString(attr, "Label") ?? targetType.Name; + var schemaUrl = GetNamedArgString(attr, "SchemaUrl"); + info.Children.Add(new ChildTableInfo(targetType.Name, ToCamelCase(foreignKey), label, schemaUrl)); + } + break; + + case RowActionAttributeFqn: + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string raName) + { + info.RowActions.Add(new RowActionInfo( + raName, + GetNamedArgString(attr, "Label") ?? raName, + GetNamedArgString(attr, "Icon"), + GetNamedArgString(attr, "Endpoint") ?? "", + (GetNamedArgString(attr, "Method") ?? "GET").ToUpperInvariant(), + GetNamedArgString(attr, "Confirmation"), + GetNamedArgString(attr, "Permission"))); + } + break; + + case ToolbarActionAttributeFqn: + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string taName) + { + info.ToolbarActions.Add(new ToolbarActionInfo( + taName, + GetNamedArgString(attr, "Label") ?? taName, + GetNamedArgString(attr, "Icon"), + GetNamedArgString(attr, "Endpoint") ?? "", + (GetNamedArgString(attr, "Method") ?? "POST").ToUpperInvariant(), + GetNamedArgString(attr, "Confirmation"), + GetNamedArgString(attr, "Permission"), + (GetNamedArgString(attr, "SelectionMode") ?? "none").ToLowerInvariant())); + } + break; + + case PermissionAttributeFqn: + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string perm) + permissions.ViewPermission = perm; + break; + + case ColumnPermissionAttributeFqn: + if (attr.ConstructorArguments.Length >= 2 + && attr.ConstructorArguments[0].Value is string colName + && attr.ConstructorArguments[1].Value is string colPerm) + { + permissions.ColumnPermissions[ToCamelCase(colName)] = colPerm; + } + break; + + case DataFilterAttributeFqn: + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string filterProp) + permissions.DataFilters.Add(ToCamelCase(filterProp)); + break; + } + } + + if (permissions.HasAny) + info.Permissions = permissions; + } } diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.JsonGeneration.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.JsonGeneration.cs index 93eb16c..5816c2a 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.JsonGeneration.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.JsonGeneration.cs @@ -231,6 +231,17 @@ private static string BuildTableJsonString(TableClassInfo info) sb.Append(string.Join(",", col.EnumValues.Select(v => $"\"{JsonEscape(v)}\""))); sb.Append("]"); } + if (col.IsComputed) sb.Append(",\"computed\":true"); + if (col.Styles.Count > 0) + { + sb.Append(",\"styles\":["); + for (int j = 0; j < col.Styles.Count; j++) + { + if (j > 0) sb.Append(","); + sb.Append($"{{\"when\":\"{JsonEscape(col.Styles[j].When)}\",\"severity\":\"{JsonEscape(col.Styles[j].Severity)}\"}}"); + } + sb.Append("]"); + } sb.Append("}"); } sb.Append("],"); @@ -248,6 +259,87 @@ private static string BuildTableJsonString(TableClassInfo info) sb.Append("\"defaultSort\":null"); } + // Children + if (info.Children.Count > 0) + { + sb.Append(",\"children\":["); + for (int i = 0; i < info.Children.Count; i++) + { + if (i > 0) sb.Append(","); + var c = info.Children[i]; + sb.Append($"{{\"label\":\"{JsonEscape(c.Label)}\",\"target\":\"{JsonEscape(c.TargetTypeName)}\",\"foreignKey\":\"{JsonEscape(c.ForeignKey)}\""); + if (c.SchemaUrl != null) sb.Append($",\"schemaUrl\":\"{JsonEscape(c.SchemaUrl)}\""); + sb.Append("}"); + } + sb.Append("]"); + } + + // RowActions + if (info.RowActions.Count > 0) + { + sb.Append(",\"rowActions\":["); + for (int i = 0; i < info.RowActions.Count; i++) + { + if (i > 0) sb.Append(","); + var ra = info.RowActions[i]; + sb.Append($"{{\"name\":\"{JsonEscape(ra.Name)}\",\"label\":\"{JsonEscape(ra.Label)}\""); + if (ra.Icon != null) sb.Append($",\"icon\":\"{JsonEscape(ra.Icon)}\""); + sb.Append($",\"endpoint\":\"{JsonEscape(ra.Endpoint)}\",\"method\":\"{JsonEscape(ra.Method)}\""); + if (ra.Confirmation != null) sb.Append($",\"confirmation\":\"{JsonEscape(ra.Confirmation)}\""); + if (ra.Permission != null) sb.Append($",\"permission\":\"{JsonEscape(ra.Permission)}\""); + sb.Append("}"); + } + sb.Append("]"); + } + + // ToolbarActions + if (info.ToolbarActions.Count > 0) + { + sb.Append(",\"toolbarActions\":["); + for (int i = 0; i < info.ToolbarActions.Count; i++) + { + if (i > 0) sb.Append(","); + var ta = info.ToolbarActions[i]; + sb.Append($"{{\"name\":\"{JsonEscape(ta.Name)}\",\"label\":\"{JsonEscape(ta.Label)}\""); + if (ta.Icon != null) sb.Append($",\"icon\":\"{JsonEscape(ta.Icon)}\""); + sb.Append($",\"endpoint\":\"{JsonEscape(ta.Endpoint)}\",\"method\":\"{JsonEscape(ta.Method)}\""); + if (ta.Confirmation != null) sb.Append($",\"confirmation\":\"{JsonEscape(ta.Confirmation)}\""); + if (ta.Permission != null) sb.Append($",\"permission\":\"{JsonEscape(ta.Permission)}\""); + sb.Append($",\"selectionMode\":\"{JsonEscape(ta.SelectionMode)}\""); + sb.Append("}"); + } + sb.Append("]"); + } + + // Permissions + if (info.Permissions != null) + { + sb.Append(",\"permissions\":{"); + if (info.Permissions.ViewPermission != null) + sb.Append($"\"view\":\"{JsonEscape(info.Permissions.ViewPermission)}\""); + if (info.Permissions.ColumnPermissions.Count > 0) + { + if (info.Permissions.ViewPermission != null) sb.Append(","); + sb.Append("\"columns\":{"); + bool first = true; + foreach (var kv in info.Permissions.ColumnPermissions) + { + if (!first) sb.Append(","); + first = false; + sb.Append($"\"{JsonEscape(kv.Key)}\":\"{JsonEscape(kv.Value)}\""); + } + sb.Append("}"); + } + if (info.Permissions.DataFilters.Count > 0) + { + if (info.Permissions.ViewPermission != null || info.Permissions.ColumnPermissions.Count > 0) sb.Append(","); + sb.Append("\"dataFilters\":["); + sb.Append(string.Join(",", info.Permissions.DataFilters.Select(f => $"\"{JsonEscape(f)}\""))); + sb.Append("]"); + } + sb.Append("}"); + } + sb.Append("}"); return sb.ToString(); } diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.RuntimeTypes.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.RuntimeTypes.cs index 27e5443..6b5d7ff 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.RuntimeTypes.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.RuntimeTypes.cs @@ -154,17 +154,29 @@ public sealed class TableDescriptor public System.Collections.Generic.IReadOnlyList Columns { get; } public PaginationDescriptor Pagination { get; } public SortDescriptor? DefaultSort { get; } + public System.Collections.Generic.IReadOnlyList Children { get; } + public System.Collections.Generic.IReadOnlyList RowActions { get; } + public System.Collections.Generic.IReadOnlyList ToolbarActions { get; } + public PermissionDescriptor? Permissions { get; } public TableDescriptor( string name, System.Collections.Generic.IReadOnlyList columns, PaginationDescriptor pagination, - SortDescriptor? defaultSort = null) + SortDescriptor? defaultSort = null, + System.Collections.Generic.IReadOnlyList? children = null, + System.Collections.Generic.IReadOnlyList? rowActions = null, + System.Collections.Generic.IReadOnlyList? toolbarActions = null, + PermissionDescriptor? permissions = null) { Name = name; Columns = columns; Pagination = pagination; DefaultSort = defaultSort; + Children = children ?? System.Array.Empty(); + RowActions = rowActions ?? System.Array.Empty(); + ToolbarActions = toolbarActions ?? System.Array.Empty(); + Permissions = permissions; } } @@ -181,6 +193,8 @@ public sealed class TableColumnDescriptor public bool IsVisible { get; } public string? Width { get; } public System.Collections.Generic.IReadOnlyList? Options { get; } + public bool IsComputed { get; } + public System.Collections.Generic.IReadOnlyList? Styles { get; } public TableColumnDescriptor( string name, @@ -192,7 +206,9 @@ public TableColumnDescriptor( int order = 0, bool isVisible = true, string? width = null, - System.Collections.Generic.IReadOnlyList? options = null) + System.Collections.Generic.IReadOnlyList? options = null, + bool isComputed = false, + System.Collections.Generic.IReadOnlyList? styles = null) { Name = name; Type = type; @@ -204,6 +220,86 @@ public TableColumnDescriptor( IsVisible = isVisible; Width = width; Options = options; + IsComputed = isComputed; + Styles = styles; + } + } + + /// Describes a child table relationship for drill-down. + public sealed class ChildTableDescriptor + { + public string Label { get; } + public string Target { get; } + public string ForeignKey { get; } + public string? SchemaUrl { get; } + + public ChildTableDescriptor(string label, string target, string foreignKey, string? schemaUrl = null) + { + Label = label; + Target = target; + ForeignKey = foreignKey; + SchemaUrl = schemaUrl; + } + } + + /// Describes a per-row action. + public sealed class RowActionDescriptor + { + public string Name { get; } + public string Label { get; } + public string? Icon { get; } + public string Endpoint { get; } + public string Method { get; } + public string? Confirmation { get; } + public string? Permission { get; } + + public RowActionDescriptor(string name, string label, string? icon, string endpoint, string method, string? confirmation = null, string? permission = null) + { + Name = name; Label = label; Icon = icon; Endpoint = endpoint; Method = method; Confirmation = confirmation; Permission = permission; + } + } + + /// Describes a toolbar-level action. + public sealed class ToolbarActionDescriptor + { + public string Name { get; } + public string Label { get; } + public string? Icon { get; } + public string Endpoint { get; } + public string Method { get; } + public string? Confirmation { get; } + public string? Permission { get; } + public string SelectionMode { get; } + + public ToolbarActionDescriptor(string name, string label, string? icon, string endpoint, string method, string? confirmation = null, string? permission = null, string selectionMode = ""none"") + { + Name = name; Label = label; Icon = icon; Endpoint = endpoint; Method = method; Confirmation = confirmation; Permission = permission; SelectionMode = selectionMode; + } + } + + /// Describes permission metadata for a table/form. + public sealed class PermissionDescriptor + { + public string? View { get; } + public System.Collections.Generic.IReadOnlyDictionary? Columns { get; } + public System.Collections.Generic.IReadOnlyList? DataFilters { get; } + + public PermissionDescriptor(string? view = null, System.Collections.Generic.IReadOnlyDictionary? columns = null, System.Collections.Generic.IReadOnlyList? dataFilters = null) + { + View = view; Columns = columns; DataFilters = dataFilters; + } + } + + /// Describes conditional styling for a column. + public sealed class ColumnStyleDescriptor + { + public string When { get; } + public string Severity { get; } + + public ColumnStyleDescriptor(string when, string severity) + { + When = when; + Severity = severity; } } diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.TableGeneration.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.TableGeneration.cs index a9bd4c8..3f58b95 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.TableGeneration.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.TableGeneration.cs @@ -44,14 +44,102 @@ private static string GenerateTableDescriptor(TableClassInfo info) sb.AppendLine($" pagination: new PaginationDescriptor({info.DefaultPageSize}, new[] {{ {pageSizesStr} }}),"); // DefaultSort + var hasErpAttrs = info.Children.Count > 0 || info.RowActions.Count > 0 || info.ToolbarActions.Count > 0 || info.Permissions != null; + var sortTrail = hasErpAttrs ? "," : ""; if (info.DefaultSort != null) { var sortCol = ToCamelCase(info.DefaultSort); - sb.AppendLine($" defaultSort: new SortDescriptor(\"{EscapeString(sortCol)}\", \"{EscapeString(info.DefaultSortDirection)}\")"); + sb.AppendLine($" defaultSort: new SortDescriptor(\"{EscapeString(sortCol)}\", \"{EscapeString(info.DefaultSortDirection)}\"){sortTrail}"); } else { - sb.AppendLine(" defaultSort: null"); + sb.AppendLine($" defaultSort: null{sortTrail}"); + } + + // ERP sections — collect and emit with proper comma handling + var erpSections = new System.Collections.Generic.List(); + + if (info.Children.Count > 0) + { + var csb = new StringBuilder(); + csb.AppendLine(" children: new ChildTableDescriptor[]"); + csb.AppendLine(" {"); + foreach (var child in info.Children) + { + var schemaUrlParam = child.SchemaUrl != null ? $", \"{EscapeString(child.SchemaUrl)}\"" : ""; + csb.AppendLine($" new ChildTableDescriptor(\"{EscapeString(child.Label)}\", \"{EscapeString(child.TargetTypeName)}\", \"{EscapeString(child.ForeignKey)}\"{schemaUrlParam}),"); + } + csb.Append(" }"); + erpSections.Add(csb.ToString()); + } + + if (info.RowActions.Count > 0) + { + var csb = new StringBuilder(); + csb.AppendLine(" rowActions: new RowActionDescriptor[]"); + csb.AppendLine(" {"); + foreach (var ra in info.RowActions) + { + var confirm = ra.Confirmation != null ? $", \"{EscapeString(ra.Confirmation)}\"" : ", null"; + var perm = ra.Permission != null ? $", \"{EscapeString(ra.Permission)}\"" : ", null"; + csb.AppendLine($" new RowActionDescriptor(\"{EscapeString(ra.Name)}\", \"{EscapeString(ra.Label)}\", {(ra.Icon != null ? $"\"{EscapeString(ra.Icon)}\"" : "null")}, \"{EscapeString(ra.Endpoint)}\", \"{EscapeString(ra.Method)}\"{confirm}{perm}),"); + } + csb.Append(" }"); + erpSections.Add(csb.ToString()); + } + + if (info.ToolbarActions.Count > 0) + { + var csb = new StringBuilder(); + csb.AppendLine(" toolbarActions: new ToolbarActionDescriptor[]"); + csb.AppendLine(" {"); + foreach (var ta in info.ToolbarActions) + { + var confirm = ta.Confirmation != null ? $", \"{EscapeString(ta.Confirmation)}\"" : ", null"; + var perm = ta.Permission != null ? $", \"{EscapeString(ta.Permission)}\"" : ", null"; + csb.AppendLine($" new ToolbarActionDescriptor(\"{EscapeString(ta.Name)}\", \"{EscapeString(ta.Label)}\", {(ta.Icon != null ? $"\"{EscapeString(ta.Icon)}\"" : "null")}, \"{EscapeString(ta.Endpoint)}\", \"{EscapeString(ta.Method)}\"{confirm}{perm}, \"{EscapeString(ta.SelectionMode)}\"),"); + } + csb.Append(" }"); + erpSections.Add(csb.ToString()); + } + + if (info.Permissions != null) + { + var csb = new StringBuilder(); + csb.AppendLine(" permissions: new PermissionDescriptor("); + csb.AppendLine($" view: {(info.Permissions.ViewPermission != null ? $"\"{EscapeString(info.Permissions.ViewPermission)}\"" : "null")},"); + if (info.Permissions.ColumnPermissions.Count > 0) + { + csb.AppendLine(" columns: new Dictionary"); + csb.AppendLine(" {"); + foreach (var kv in info.Permissions.ColumnPermissions) + csb.AppendLine($" [\"{EscapeString(kv.Key)}\"] = \"{EscapeString(kv.Value)}\","); + csb.AppendLine(" },"); + } + else + { + csb.AppendLine(" columns: null,"); + } + if (info.Permissions.DataFilters.Count > 0) + { + var filters = string.Join(", ", info.Permissions.DataFilters.Select(f => $"\"{EscapeString(f)}\"")); + csb.AppendLine($" dataFilters: new[] {{ {filters} }}"); + } + else + { + csb.AppendLine(" dataFilters: null"); + } + csb.Append(" )"); + erpSections.Add(csb.ToString()); + } + + for (int i = 0; i < erpSections.Count; i++) + { + sb.Append(erpSections[i]); + if (i < erpSections.Count - 1) + sb.AppendLine(","); + else + sb.AppendLine(); } sb.AppendLine(" );"); @@ -89,6 +177,18 @@ private static void EmitTableColumn(StringBuilder sb, TableColumnInfo col) sb.AppendLine($" options: new[] {{ {optionsStr} }},"); } + if (col.IsComputed) + sb.AppendLine(" isComputed: true,"); + + if (col.Styles.Count > 0) + { + sb.AppendLine(" styles: new ColumnStyleDescriptor[]"); + sb.AppendLine(" {"); + foreach (var style in col.Styles) + sb.AppendLine($" new ColumnStyleDescriptor(\"{EscapeString(style.When)}\", \"{EscapeString(style.Severity)}\"),"); + sb.AppendLine(" },"); + } + sb.AppendLine($" isVisible: {(col.IsVisible ? "true" : "false")}),"); } } diff --git a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.cs b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.cs index 1967978..f652119 100644 --- a/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.cs +++ b/packages/ZibStack.NET.UI/src/ZibStack.NET.UI/UiGenerator.cs @@ -27,6 +27,18 @@ public sealed partial class UiGenerator : IIncrementalGenerator private const string TableColumnAttributeFqn = "ZibStack.NET.UI.TableColumnAttribute"; private const string TableIgnoreAttributeFqn = "ZibStack.NET.UI.TableIgnoreAttribute"; + // ERP: class-level + private const string ChildTableAttributeFqn = "ZibStack.NET.UI.ChildTableAttribute"; + private const string RowActionAttributeFqn = "ZibStack.NET.UI.RowActionAttribute"; + private const string ToolbarActionAttributeFqn = "ZibStack.NET.UI.ToolbarActionAttribute"; + private const string PermissionAttributeFqn = "ZibStack.NET.UI.PermissionAttribute"; + private const string ColumnPermissionAttributeFqn = "ZibStack.NET.UI.ColumnPermissionAttribute"; + private const string DataFilterAttributeFqn = "ZibStack.NET.UI.DataFilterAttribute"; + + // ERP: property-level + private const string ComputedAttributeFqn = "ZibStack.NET.UI.ComputedAttribute"; + private const string ColumnStyleAttributeFqn = "ZibStack.NET.UI.ColumnStyleAttribute"; + // UI control hints private const string TextAreaAttributeFqn = "ZibStack.NET.UI.TextAreaAttribute"; private const string SelectAttributeFqn = "ZibStack.NET.UI.SelectAttribute"; @@ -77,6 +89,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("SliderAttribute.g.cs", SliderAttributeSource); ctx.AddSource("PasswordInputAttribute.g.cs", PasswordInputAttributeSource); + // ERP attributes + ctx.AddSource("ChildTableAttribute.g.cs", ChildTableAttributeSource); + ctx.AddSource("RowActionAttribute.g.cs", RowActionAttributeSource); + ctx.AddSource("ToolbarActionAttribute.g.cs", ToolbarActionAttributeSource); + ctx.AddSource("PermissionAttribute.g.cs", PermissionAttributeSource); + ctx.AddSource("ColumnPermissionAttribute.g.cs", ColumnPermissionAttributeSource); + ctx.AddSource("DataFilterAttribute.g.cs", DataFilterAttributeSource); + ctx.AddSource("ComputedAttribute.g.cs", ComputedAttributeSource); + ctx.AddSource("ColumnStyleAttribute.g.cs", ColumnStyleAttributeSource); + // Runtime types ctx.AddSource("FormDescriptor.g.cs", FormDescriptorSource); ctx.AddSource("TableDescriptor.g.cs", TableDescriptorSource); diff --git a/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/FormTests.cs b/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/FormTests.cs index 554ca17..aab265d 100644 --- a/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/FormTests.cs +++ b/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/FormTests.cs @@ -346,3 +346,136 @@ public void Player_FormJson_ContainsConditional() Assert.Contains("\"operator\":\"equals\"", json); } } + +public class ErpTests +{ + [Fact] + public void Voivodeship_HasChildTables() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + Assert.Equal(2, descriptor.Children.Count); + Assert.Equal("Powiaty", descriptor.Children[0].Label); + Assert.Equal("CountyView", descriptor.Children[0].Target); + Assert.Equal("voivodeshipId", descriptor.Children[0].ForeignKey); + Assert.Equal("Kody pocztowe", descriptor.Children[1].Label); + } + + [Fact] + public void Voivodeship_HasRowActions() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + Assert.Equal(2, descriptor.RowActions.Count); + + var details = descriptor.RowActions[0]; + Assert.Equal("showDetails", details.Name); + Assert.Equal("Szczegóły", details.Label); + Assert.Equal("/api/voivodeships/{id}", details.Endpoint); + Assert.Equal("GET", details.Method); + + var report = descriptor.RowActions[1]; + Assert.Equal("generateReport", report.Name); + Assert.Equal("file", report.Icon); + Assert.Equal("POST", report.Method); + Assert.Equal("Wygenerować raport?", report.Confirmation); + } + + [Fact] + public void Voivodeship_HasToolbarActions() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + Assert.Equal(2, descriptor.ToolbarActions.Count); + + var export = descriptor.ToolbarActions[0]; + Assert.Equal("export", export.Name); + Assert.Equal("multiple", export.SelectionMode); + Assert.Equal("download", export.Icon); + + var recalc = descriptor.ToolbarActions[1]; + Assert.Equal("recalculate", recalc.Name); + Assert.Equal("Przeliczyć salda?", recalc.Confirmation); + Assert.Equal("finance.write", recalc.Permission); + } + + [Fact] + public void Voivodeship_HasPermissions() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + Assert.NotNull(descriptor.Permissions); + Assert.Equal("voivodeship.read", descriptor.Permissions!.View); + Assert.Equal("finance.read", descriptor.Permissions.Columns!["budget"]); + Assert.Contains("voivodeshipId", descriptor.Permissions.DataFilters!); + } + + [Fact] + public void Voivodeship_BudgetColumn_IsComputed() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + var budget = descriptor.Columns.First(c => c.Name == "budget"); + Assert.True(budget.IsComputed); + } + + [Fact] + public void Voivodeship_BudgetColumn_HasStyles() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + var budget = descriptor.Columns.First(c => c.Name == "budget"); + Assert.NotNull(budget.Styles); + Assert.Equal(2, budget.Styles!.Count); + Assert.Equal("value < 0", budget.Styles[0].When); + Assert.Equal("danger", budget.Styles[0].Severity); + Assert.Equal("value >= 0", budget.Styles[1].When); + Assert.Equal("success", budget.Styles[1].Severity); + } + + [Fact] + public void Voivodeship_CountyCountColumn_IsComputed() + { + var descriptor = VoivodeshipView.GetTableDescriptor(); + var col = descriptor.Columns.First(c => c.Name == "countyCount"); + Assert.True(col.IsComputed); + } + + [Fact] + public void Voivodeship_Json_ContainsChildren() + { + var json = VoivodeshipView.GetTableSchemaJson(); + Assert.Contains("\"children\":[", json); + Assert.Contains("\"target\":\"CountyView\"", json); + Assert.Contains("\"foreignKey\":\"voivodeshipId\"", json); + } + + [Fact] + public void Voivodeship_Json_ContainsRowActions() + { + var json = VoivodeshipView.GetTableSchemaJson(); + Assert.Contains("\"rowActions\":[", json); + Assert.Contains("\"endpoint\":\"/api/voivodeships/{id}\"", json); + } + + [Fact] + public void Voivodeship_Json_ContainsToolbarActions() + { + var json = VoivodeshipView.GetTableSchemaJson(); + Assert.Contains("\"toolbarActions\":[", json); + Assert.Contains("\"selectionMode\":\"multiple\"", json); + } + + [Fact] + public void Voivodeship_Json_ContainsPermissions() + { + var json = VoivodeshipView.GetTableSchemaJson(); + Assert.Contains("\"permissions\":{", json); + Assert.Contains("\"view\":\"voivodeship.read\"", json); + Assert.Contains("\"finance.read\"", json); + Assert.Contains("\"dataFilters\":[", json); + } + + [Fact] + public void Voivodeship_Json_ContainsComputedAndStyles() + { + var json = VoivodeshipView.GetTableSchemaJson(); + Assert.Contains("\"computed\":true", json); + Assert.Contains("\"styles\":[", json); + Assert.Contains("\"severity\":\"danger\"", json); + } +} diff --git a/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/Models.cs b/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/Models.cs index 387b718..e845ec5 100644 --- a/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/Models.cs +++ b/packages/ZibStack.NET.UI/tests/ZibStack.NET.UI.Tests/Models.cs @@ -117,3 +117,47 @@ public partial class FormWithRadioAndFile [FormDisabled] public string? LockedField { get; set; } } + +// ─── ERP models ────────────────────────────────────────────────────── + +public partial class CountyView { } +public partial class PostalCodeView { } + +[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", Icon = "download", + Endpoint = "/api/voivodeships/export", SelectionMode = "multiple")] +[ToolbarAction("recalculate", Label = "Przelicz salda", + Endpoint = "/api/voivodeships/recalculate", Method = "POST", + Confirmation = "Przeliczyć salda?", Permission = "finance.write")] +public partial class VoivodeshipView +{ + [TableColumn(IsVisible = false)] + public int Id { get; set; } + + [TableColumn(Sortable = true, Filterable = true)] + public string Name { get; set; } = ""; + + [TableColumn(Sortable = true)] + public string Code { get; set; } = ""; + + [TableColumn(Sortable = true)] + [Computed] + [ColumnStyle(When = "value < 0", Severity = "danger")] + [ColumnStyle(When = "value >= 0", Severity = "success")] + public decimal Budget { get; set; } + + [TableColumn(Sortable = true)] + [Computed] + public int CountyCount { get; set; } + + public int VoivodeshipId { get; set; } +} From 08956b35c2caebdf07c2e2a41e0922437f99dbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 20:50:41 +0200 Subject: [PATCH 5/6] Add ERP examples to all samples (Blazor, React, API) - VoivodeshipView model with ChildTable, RowAction, ToolbarAction, Permission, Computed, ColumnStyle attributes - SampleApi: new endpoints for voivodeship/county/postalcode schemas - SampleBlazor: ErpDemo page with drill-down, row/toolbar actions, conditional cell styling, permission info display - React: ErpTable component with row selection, toolbar actions, drill-down buttons, severity-based cell colors, action log --- .../SampleApi/Controllers/FormsController.cs | 17 ++ .../sample/SampleApi/Models/Voivodeship.cs | 95 ++++++++ .../sample/SampleBlazor/Components/App.razor | 1 + .../Components/Pages/ErpDemo.razor | 207 ++++++++++++++++++ .../sample/SampleBlazor/Models/Voivodeship.cs | 86 ++++++++ .../sample/react-app/src/App.tsx | 34 ++- .../react-app/src/components/ErpTable.tsx | 182 +++++++++++++++ 7 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 packages/ZibStack.NET.UI/sample/SampleApi/Models/Voivodeship.cs create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/ErpDemo.razor create mode 100644 packages/ZibStack.NET.UI/sample/SampleBlazor/Models/Voivodeship.cs create mode 100644 packages/ZibStack.NET.UI/sample/react-app/src/components/ErpTable.tsx diff --git a/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs b/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs index 884f6d4..70f6503 100644 --- a/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs +++ b/packages/ZibStack.NET.UI/sample/SampleApi/Controllers/FormsController.cs @@ -14,4 +14,21 @@ public IActionResult GetPlayerForm() [HttpGet("tables/player")] public IActionResult GetPlayerTable() => Content(Player.GetTableSchemaJson(), "application/json"); + + // ERP-style views + [HttpGet("forms/voivodeship")] + public IActionResult GetVoivodeshipForm() + => Content(VoivodeshipView.GetFormSchemaJson(), "application/json"); + + [HttpGet("tables/voivodeship")] + public IActionResult GetVoivodeshipTable() + => Content(VoivodeshipView.GetTableSchemaJson(), "application/json"); + + [HttpGet("tables/county")] + public IActionResult GetCountyTable() + => Content(CountyView.GetTableSchemaJson(), "application/json"); + + [HttpGet("tables/postalcode")] + public IActionResult GetPostalCodeTable() + => Content(PostalCodeView.GetTableSchemaJson(), "application/json"); } diff --git a/packages/ZibStack.NET.UI/sample/SampleApi/Models/Voivodeship.cs b/packages/ZibStack.NET.UI/sample/SampleApi/Models/Voivodeship.cs new file mode 100644 index 0000000..c5242d6 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleApi/Models/Voivodeship.cs @@ -0,0 +1,95 @@ +using ZibStack.NET.UI; + +namespace SampleApi.Models; + +// ─── ERP-style hierarchical views ──────────────────────────────────── + +[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/Components/App.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor index c040752..0861649 100644 --- a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/App.razor @@ -28,6 +28,7 @@ diff --git a/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/ErpDemo.razor b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/ErpDemo.razor new file mode 100644 index 0000000..61b46ff --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/SampleBlazor/Components/Pages/ErpDemo.razor @@ -0,0 +1,207 @@ +@page "/erp" +@using SampleBlazor.Models +@using System.Text.Json + +

ERP Demo — Voivodeship Table with Drill-Down

+ +

This view is generated from VoivodeshipView which uses ERP attributes: +[ChildTable], [RowAction], [ToolbarAction], +[Permission], [Computed], [ColumnStyle].

+ +@if (_tableSchema is not null) +{ + @* ── Toolbar Actions ──────────────────────────────────────────── *@ + @if (_tableSchema.RootElement.TryGetProperty("toolbarActions", out var toolbarActions)) + { +
+ @foreach (var action in toolbarActions.EnumerateArray()) + { + + } +
+ } + + @* ── Table ────────────────────────────────────────────────────── *@ + + + + @foreach (var col in GetVisibleColumns()) + { + + } + + + + + @foreach (var row in _sampleData) + { + + @foreach (var col in GetVisibleColumns()) + { + var colName = col.GetProperty("name").GetString()!; + var value = row.GetValueOrDefault(colName, ""); + var style = GetCellStyle(col, value); + + } + + + } + +
+ @(col.TryGetProperty("label", out var lbl) ? lbl.GetString() : col.GetProperty("name").GetString()) + @if (col.TryGetProperty("computed", out _)) + { + (calc) + } + Actions
@value + @* Row Actions *@ + @if (_tableSchema.RootElement.TryGetProperty("rowActions", out var rowActions)) + { + @foreach (var action in rowActions.EnumerateArray()) + { + var endpoint = action.GetProperty("endpoint").GetString()! + .Replace("{id}", row["id"]); + + } + } + @* Child Table Drill-Down *@ + @if (_tableSchema.RootElement.TryGetProperty("children", out var children)) + { + @foreach (var child in children.EnumerateArray()) + { + + } + } +
+ + @* ── Permissions Info ─────────────────────────────────────────── *@ + @if (_tableSchema.RootElement.TryGetProperty("permissions", out var perms)) + { +
+ Permission metadata +
@JsonSerializer.Serialize(perms, new JsonSerializerOptions { WriteIndented = true })
+
+ } +} + +@if (_actionLog.Count > 0) +{ +

Action Log

+
    + @foreach (var log in _actionLog) + { +
  • @log
  • + } +
+} + +
+ +

Raw JSON Schema

+
@_prettyJson
+ +@code { + private JsonDocument? _tableSchema; + private string _prettyJson = ""; + private List _actionLog = new(); + + private List> _sampleData = new() + { + new() { ["id"] = "1", ["name"] = "Wielkopolskie", ["code"] = "WP", ["capital"] = "Poznań", + ["budget"] = "1250000", ["countyCount"] = "35", ["population"] = "3498733" }, + new() { ["id"] = "2", ["name"] = "Mazowieckie", ["code"] = "MZ", ["capital"] = "Warszawa", + ["budget"] = "-500000", ["countyCount"] = "42", ["population"] = "5411446" }, + new() { ["id"] = "3", ["name"] = "Małopolskie", ["code"] = "MA", ["capital"] = "Kraków", + ["budget"] = "750000", ["countyCount"] = "22", ["population"] = "3400577" }, + }; + + protected override void OnInitialized() + { + var json = VoivodeshipView.GetTableSchemaJson(); + _tableSchema = JsonDocument.Parse(json); + _prettyJson = JsonSerializer.Serialize(_tableSchema, new JsonSerializerOptions { WriteIndented = true }); + } + + private IEnumerable GetVisibleColumns() + { + if (_tableSchema is null) yield break; + foreach (var col in _tableSchema.RootElement.GetProperty("columns").EnumerateArray()) + { + if (col.TryGetProperty("visible", out var vis) && !vis.GetBoolean()) continue; + yield return col; + } + } + + private string GetCellStyle(JsonElement col, string value) + { + if (!col.TryGetProperty("styles", out var styles)) return ""; + if (!decimal.TryParse(value, out var numValue)) return ""; + + foreach (var style in styles.EnumerateArray()) + { + var when = style.GetProperty("when").GetString() ?? ""; + var severity = style.GetProperty("severity").GetString() ?? ""; + // Simple expression evaluation for "value < 0" / "value >= 0" + bool matches = when switch + { + "value < 0" => numValue < 0, + "value >= 0" => numValue >= 0, + "value > 1000" => numValue > 1000, + _ => false + }; + if (matches) + { + return severity switch + { + "danger" => "color: red; font-weight: bold;", + "warning" => "color: orange;", + "success" => "color: green; font-weight: bold;", + "info" => "color: blue;", + "muted" => "color: #999;", + _ => "" + }; + } + } + return ""; + } + + private void HandleRowAction(JsonElement action, Dictionary row) + { + var label = action.GetProperty("label").GetString(); + var endpoint = action.GetProperty("endpoint").GetString()!.Replace("{id}", row["id"]); + var method = action.GetProperty("method").GetString(); + + if (action.TryGetProperty("confirmation", out var conf)) + _actionLog.Add($"[Confirm: {conf}] {method} {endpoint}"); + else + _actionLog.Add($"{method} {endpoint} — {label}"); + } + + private void HandleToolbarAction(JsonElement action) + { + var label = action.GetProperty("label").GetString(); + var endpoint = action.GetProperty("endpoint").GetString(); + _actionLog.Add($"Toolbar: {label} → {endpoint}"); + } + + private void DrillDown(JsonElement child, Dictionary row) + { + var label = child.GetProperty("label").GetString(); + var fk = child.GetProperty("foreignKey").GetString(); + var target = child.GetProperty("target").GetString(); + _actionLog.Add($"Drill-down: {label} → {target}?{fk}={row["id"]}"); + } +} 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/react-app/src/App.tsx b/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx index c308f87..c980a4c 100644 --- a/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx +++ b/packages/ZibStack.NET.UI/sample/react-app/src/App.tsx @@ -1,5 +1,6 @@ import { DynamicForm } from './components/DynamicForm'; import { DynamicTable } from './components/DynamicTable'; +import { ErpTable } from './components/ErpTable'; /** * Sample app demonstrating ZibStack.NET.UI with React. @@ -9,22 +10,27 @@ import { DynamicTable } from './components/DynamicTable'; * 2. The API serves form/table schemas at /api/forms/player and /api/tables/player */ -// Example data for the table demo 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 the JSON schema served by Player.GetFormSchemaJson(). + This form is rendered from Player.GetFormSchemaJson(). Select "Admin" as the role to see conditional field visibility in action.

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/ErpTable.tsx b/packages/ZibStack.NET.UI/sample/react-app/src/components/ErpTable.tsx new file mode 100644 index 0000000..41ae7a1 --- /dev/null +++ b/packages/ZibStack.NET.UI/sample/react-app/src/components/ErpTable.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react'; +import { useTableSchema, ColumnSchema } from '../hooks/useFormSchema'; + +interface Props { + schemaUrl: string; + data: Record[]; + totalCount: number; + onDrillDown?: (target: string, foreignKey: string, parentId: any) => void; +} + +/** + * ERP-style table with drill-down, row actions, toolbar actions, + * computed columns, conditional styling, and permission metadata. + */ +export function ErpTable({ schemaUrl, data, totalCount, onDrillDown }: Props) { + const { schema, loading, error } = useTableSchema(schemaUrl); + const [actionLog, setActionLog] = useState([]); + const [selectedRows, setSelectedRows] = useState>(new Set()); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!schema) return null; + + const raw = schema as any; + const children = raw.children ?? []; + const rowActions = raw.rowActions ?? []; + const toolbarActions = raw.toolbarActions ?? []; + const permissions = raw.permissions; + + const visibleColumns = (schema.columns as any[]) + .filter((c: any) => c.visible !== false) + .sort((a: any, b: any) => a.order - b.order); + + const log = (msg: string) => setActionLog(prev => [...prev, msg]); + + const handleRowAction = (action: any, row: Record) => { + const endpoint = action.endpoint.replace('{id}', row.id); + if (action.confirmation && !confirm(action.confirmation)) return; + log(`${action.method} ${endpoint} — ${action.label}`); + }; + + const handleToolbarAction = (action: any) => { + if (action.confirmation && !confirm(action.confirmation)) return; + const ids = action.selectionMode === 'multiple' + ? Array.from(selectedRows).join(',') + : ''; + log(`Toolbar: ${action.label} → ${action.endpoint}${ids ? `?ids=${ids}` : ''}`); + }; + + const handleDrillDown = (child: any, row: Record) => { + log(`Drill-down: ${child.label} → ${child.target}?${child.foreignKey}=${row.id}`); + onDrillDown?.(child.target, child.foreignKey, row.id); + }; + + const getCellStyle = (col: any, value: any): React.CSSProperties => { + if (!col.styles) return {}; + const num = Number(value); + if (isNaN(num)) return {}; + + for (const style of col.styles) { + let matches = false; + if (style.when === 'value < 0') matches = num < 0; + else if (style.when === 'value >= 0') matches = num >= 0; + else if (style.when === 'value > 1000') matches = num > 1000; + + if (matches) { + const severityMap: Record = { + danger: { color: 'red', fontWeight: 'bold' }, + warning: { color: 'orange' }, + success: { color: 'green', fontWeight: 'bold' }, + info: { color: 'blue' }, + muted: { color: '#999' }, + }; + return severityMap[style.severity] ?? {}; + } + } + return {}; + }; + + const toggleRow = (i: number) => { + setSelectedRows(prev => { + const next = new Set(prev); + next.has(i) ? next.delete(i) : next.add(i); + return next; + }); + }; + + const hasMultiSelectAction = toolbarActions.some((a: any) => a.selectionMode === 'multiple'); + + return ( +
+

{schema.name}

+ + {/* Permission info */} + {permissions && ( +
+ Permissions: requires "{permissions.view}" +
{JSON.stringify(permissions, null, 2)}
+
+ )} + + {/* Toolbar */} + {toolbarActions.length > 0 && ( +
+ {toolbarActions.map((action: any) => ( + + ))} +
+ )} + + {/* Table */} + + + + {hasMultiSelectAction && ( + + )} + {visibleColumns.map((col: any) => ( + + ))} + {(rowActions.length > 0 || children.length > 0) && ( + + )} + + + + {data.map((row, i) => ( + + {hasMultiSelectAction && ( + + )} + {visibleColumns.map((col: any) => ( + + ))} + {(rowActions.length > 0 || children.length > 0) && ( + + )} + + ))} + +
+ {col.label ?? col.name} + {col.computed && (calc)} + Actions
+ toggleRow(i)} /> + + {String(row[col.name] ?? '')} + + {rowActions.map((action: any) => ( + + ))} + {children.map((child: any) => ( + + ))} +
+ + {/* Action log */} + {actionLog.length > 0 && ( +
+

Action Log

+
    {actionLog.map((msg, i) =>
  • {msg}
  • )}
+
+ )} +
+ ); +} From ea5549e94e5057299d4467e8862ab88f2e56621b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 20:52:27 +0200 Subject: [PATCH 6/6] Add ZibStack.NET.UI to root README, CI, and release workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UI package to packages table with description - Add ZibStack.NET.UI quick example (ERP-style with drill-down, actions, permissions) - Add UI to repository structure diagram - Update release-all.yml with Pack 8 — UI - Add release-ui.yml workflow for standalone release - Add Test UI step to ci.yml --- .github/workflows/ci.yml | 3 ++ .github/workflows/release-all.yml | 3 ++ .github/workflows/release-ui.yml | 30 ++++++++++++++++++++ README.md | 46 +++++++++++++++++++++++++++++-- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release-ui.yml 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 ```