From d453653b8e54ca6c7f53f5b4eccd3d01ac2bd6c9 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Fri, 23 Jan 2026 21:34:51 +0100 Subject: [PATCH 01/17] initial ivy setup --- Internal/Internal.csproj | 14 ++++++++++++++ Internal/Program.cs | 8 ++++++++ Storage.slnx | 1 + 3 files changed, 23 insertions(+) create mode 100644 Internal/Internal.csproj create mode 100644 Internal/Program.cs diff --git a/Internal/Internal.csproj b/Internal/Internal.csproj new file mode 100644 index 0000000..4c23d0f --- /dev/null +++ b/Internal/Internal.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/Internal/Program.cs b/Internal/Program.cs new file mode 100644 index 0000000..b29353e --- /dev/null +++ b/Internal/Program.cs @@ -0,0 +1,8 @@ +using Ivy; + +var server = new Server(); +server.UseHotReload(); +server.AddAppsFromAssembly(); +server.UseChrome(); + +await server.RunAsync(); \ No newline at end of file diff --git a/Storage.slnx b/Storage.slnx index 97f5e5b..46da754 100644 --- a/Storage.slnx +++ b/Storage.slnx @@ -1,4 +1,5 @@ + From bdf9470be493d1b7ea9f5c197f32a733f3b891f9 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Sat, 24 Jan 2026 02:26:36 +0100 Subject: [PATCH 02/17] add some apps and navigation --- Internal/Program.cs | 8 ------- .../Apps/Inventory/ProductsInventoryApp.cs | 16 +++++++++++++ .../Apps/Publishing/CategoriesPublishing.cs | 17 +++++++++++++ .../Apps/Publishing/ProductsPublishingApp.cs | 17 +++++++++++++ Storage.Internal/Home.cs | 22 +++++++++++++++++ Storage.Internal/Program.cs | 24 +++++++++++++++++++ .../Storage.Internal.csproj | 0 Storage.slnx | 2 +- 8 files changed, 97 insertions(+), 9 deletions(-) delete mode 100644 Internal/Program.cs create mode 100644 Storage.Internal/Apps/Inventory/ProductsInventoryApp.cs create mode 100644 Storage.Internal/Apps/Publishing/CategoriesPublishing.cs create mode 100644 Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs create mode 100644 Storage.Internal/Home.cs create mode 100644 Storage.Internal/Program.cs rename Internal/Internal.csproj => Storage.Internal/Storage.Internal.csproj (100%) diff --git a/Internal/Program.cs b/Internal/Program.cs deleted file mode 100644 index b29353e..0000000 --- a/Internal/Program.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Ivy; - -var server = new Server(); -server.UseHotReload(); -server.AddAppsFromAssembly(); -server.UseChrome(); - -await server.RunAsync(); \ No newline at end of file diff --git a/Storage.Internal/Apps/Inventory/ProductsInventoryApp.cs b/Storage.Internal/Apps/Inventory/ProductsInventoryApp.cs new file mode 100644 index 0000000..294a9d8 --- /dev/null +++ b/Storage.Internal/Apps/Inventory/ProductsInventoryApp.cs @@ -0,0 +1,16 @@ +using System; +using Ivy; +using Ivy.Views; + +namespace Storage.Internal.Apps.Inventory; + +[App(icon: Ivy.Shared.Icons.Warehouse, title: "Products Inventory")] +public class ProductsInventoryApp : ViewBase +{ + public override object? Build() + { + return new StackLayout([ + Text.H1("Products Inventory") + ]); + } +} diff --git a/Storage.Internal/Apps/Publishing/CategoriesPublishing.cs b/Storage.Internal/Apps/Publishing/CategoriesPublishing.cs new file mode 100644 index 0000000..2f250a1 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/CategoriesPublishing.cs @@ -0,0 +1,17 @@ +using System; +using Ivy; +using Ivy.Shared; +using Ivy.Views; + +namespace Storage.Internal.Apps.Publishing; + +[App(icon: Icons.Bookmark, title: "Categories Publishing")] +public class CategoriesPublishing : ViewBase +{ + public override object? Build() + { + return new StackLayout([ + Text.H1("Categories Publishing") + ]); + } +} diff --git a/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs new file mode 100644 index 0000000..274794a --- /dev/null +++ b/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs @@ -0,0 +1,17 @@ +using System; +using Ivy; +using Ivy.Shared; +using Ivy.Views; + +namespace Storage.Internal.Apps.Publishing; + +[App(icon: Icons.ShoppingBasket, title: "Products Publishing")] +public class ProductsPublishingApp : ViewBase +{ + public override object? Build() + { + return new StackLayout([ + Text.H1("Products Publishing") + ]); + } +} diff --git a/Storage.Internal/Home.cs b/Storage.Internal/Home.cs new file mode 100644 index 0000000..7e12df4 --- /dev/null +++ b/Storage.Internal/Home.cs @@ -0,0 +1,22 @@ +using System; +using Ivy; +using Ivy.Shared; +using Ivy.Views; + +namespace Storage.Internal; + +[App(icon: Icons.House, title: "Home")] +public class Home : ViewBase +{ + public override object? Build() + { + var navigator = UseNavigation(); + + return new Card( + Layout.Vertical().Gap(2) + | Text.P("Hello!").Large() + | Text.P("This is a demo app") + ).Title("Home card"); + } +} + diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs new file mode 100644 index 0000000..550533e --- /dev/null +++ b/Storage.Internal/Program.cs @@ -0,0 +1,24 @@ +using Ivy; +using Ivy.Chrome; +using Ivy.Views; +using Storage.Internal; +using Storage.Internal.Apps.Publishing; + +var chromeSettings = new ChromeSettings() + .WallpaperApp() + .Header( + Layout.Vertical().Gap(2) + | Text.Lead("hello") + ) + .DefaultApp() + .UseTabs(preventDuplicates: true); + +var server = new Server(); +server.UseHotReload(); +server.AddAppsFromAssembly(); +// server.UseDefaultApp(typeof(Demo)); +server.UseChrome(() => new DefaultSidebarChrome(chromeSettings)); + +await server.RunAsync(); + + diff --git a/Internal/Internal.csproj b/Storage.Internal/Storage.Internal.csproj similarity index 100% rename from Internal/Internal.csproj rename to Storage.Internal/Storage.Internal.csproj diff --git a/Storage.slnx b/Storage.slnx index 46da754..792db60 100644 --- a/Storage.slnx +++ b/Storage.slnx @@ -1,5 +1,5 @@ - + From b16d6abad720e080dfc14d4e5b674fd0c1ff5101 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Sat, 24 Jan 2026 12:37:49 +0100 Subject: [PATCH 03/17] scaffold StorageInternalConnection --- Storage.Internal/.gitignore | 5 + Storage.Internal/AGENTS.md | 223 ++++++++++++++++++ .../Connections/StorageInternal/Category.cs | 24 ++ .../StorageInternal/GlobalUsings.cs | 1 + .../Connections/StorageInternal/Image.cs | 25 ++ .../20260124112746_InitialCreate.Designer.cs | 176 ++++++++++++++ .../20260124112746_InitialCreate.cs | 125 ++++++++++ .../StorageInternalContextModelSnapshot.cs | 173 ++++++++++++++ .../Connections/StorageInternal/Product.cs | 44 ++++ .../StorageInternalConnection.cs | 47 ++++ .../StorageInternal/StorageInternalContext.cs | 41 ++++ .../StorageInternalContextFactory.cs | 39 +++ ...StorageInternalDesignTimeContextFactory.cs | 13 + Storage.Internal/GlobalUsings.cs | 31 +++ Storage.Internal/README.md | 20 ++ Storage.Internal/Storage.Internal.csproj | 12 + 16 files changed, 999 insertions(+) create mode 100644 Storage.Internal/.gitignore create mode 100644 Storage.Internal/AGENTS.md create mode 100644 Storage.Internal/Connections/StorageInternal/Category.cs create mode 100644 Storage.Internal/Connections/StorageInternal/GlobalUsings.cs create mode 100644 Storage.Internal/Connections/StorageInternal/Image.cs create mode 100644 Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.Designer.cs create mode 100644 Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.cs create mode 100644 Storage.Internal/Connections/StorageInternal/Migrations/StorageInternalContextModelSnapshot.cs create mode 100644 Storage.Internal/Connections/StorageInternal/Product.cs create mode 100644 Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs create mode 100644 Storage.Internal/Connections/StorageInternal/StorageInternalContext.cs create mode 100644 Storage.Internal/Connections/StorageInternal/StorageInternalContextFactory.cs create mode 100644 Storage.Internal/Connections/StorageInternal/StorageInternalDesignTimeContextFactory.cs create mode 100644 Storage.Internal/GlobalUsings.cs create mode 100644 Storage.Internal/README.md diff --git a/Storage.Internal/.gitignore b/Storage.Internal/.gitignore new file mode 100644 index 0000000..2cb9053 --- /dev/null +++ b/Storage.Internal/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.sln.iml +.idea/**/* +**/.idea/* \ No newline at end of file diff --git a/Storage.Internal/AGENTS.md b/Storage.Internal/AGENTS.md new file mode 100644 index 0000000..53e3bc1 --- /dev/null +++ b/Storage.Internal/AGENTS.md @@ -0,0 +1,223 @@ +All Ivy documentation pages are listed on: . +Add ".md" to the end of any URL to go directly to the Markdown version of the doc. + +# Introduction to the Ivy Framework for LLMs + +- Ivy is a declarative full-stack UI framework that allows developers to build user interfaces using a component-based approach very similar to React. +- In Ivy you only write one application in pure C# and we don't have a BE and FE distinction. +- UI rendering is handled by Ivy. +- When programming in Ivy you focus on building the logical structure of your application using a large set of pre-built widgets and views - you rarely need to specify any styling - Ivy just makes it look good by default. + +Terminology: + +- "Views" are the main building blocks of the UI, similar to React components. +- "Hooks" are functions that allow views to manage state and side effects. +- "Widgets" are the UI elements that make up the views. e.g., Button, TextBlock, StackPanel... +- "Apps" are the top-level views that represent entire applications. + +A view is defined as a class that inherits from `ViewBase` and implements a `Build` method. The `Build` method returns either another view or a widget. + +Widgets can have multiple children, but views can only return a single object (widget or view). To return multiple widgets from a view, you can use a `Fragment` use the Layout helpers. See below. + +public class MyView : ViewBase +{ + public override object? Build() + { + var count = UseState(0); + return Layout.Vertical() + | new Text($"Count: {count.Value}") + | new Button("Increment", () => count.Set(count.Value + 1)); + } +} + +The topmost view in an Ivy application is called an [App](https://docs.ivy.app/onboarding/concepts/apps.md) and is decorated with the `[App]` attribute. + +[App()] +public class MyApp : ViewBase + +- The convention is to put all apps in the `Apps` folder of your Ivy project. +- An app is built into a tree of widgets. This is what's rendered to the screen. + +## Common Widgets + +[Button](https://docs.ivy.app/widgets/common/button.md) +[Card](https://docs.ivy.app/widgets/common/card.md) +[Badge](https://docs.ivy.app/widgets/common/badge.md) +[Sheet](https://docs.ivy.app/widgets/advanced/sheet.md) +[Progress](https://docs.ivy.app/widgets/common/progress.md) +[Expandable](https://docs.ivy.app/widgets/common/expandable.md) +[Tooltip](https://docs.ivy.app/widgets/common/tooltip.md) +[DropDownMenu](https://docs.ivy.app/widgets/common/drop-down-menu) +[Table](https://docs.ivy.app/widgets/common/table.md) +[List](https://docs.ivy.app/widgets/common/list.md) +[Details](https://docs.ivy.app/widgets/common/details.md) +[Image](https://docs.ivy.app/widgets/primitives/image.md) +[Avatar](https://docs.ivy.app/widgets/primitives/avatar.md) +[Spacer](https://docs.ivy.app/widgets/primitives/spacer.md) +[Callout](https://docs.ivy.app/widgets/primitives/callout.md) + +## Layouts + +- Use Layout.Vertical() or Layout.Horizontal() to create stack layouts. +- Layout.Grid() +— Layout.Wrap() +- Add Children: Pipe child elements using the | operator to arrange them top-to-bottom (vertical) or left-to-right (horizontal). +- Layouts can be customized with methods like .Gap(int number) to set spacing between children. Use .Left(), .Center(), or .Right() methods to control alignment. +- The number in Gap(int number) works the same as in Tailwind CSS spacing scale (e.g., 1 = 0.25rem, 2 = 0.5rem, etc.). +Layouts have a default gap of 4 (1rem). In general, you very rarely need to set the gap explicitly. + +// Basic Vertical Layout +Layout.Vertical() + | new Badge("Top") + | new Badge("Middle") + | new Badge("Bottom"); + +// Nested layouts with alignment +Layout.Vertical().Align(Align.Center) + | Text.Label("Header") + | (Layout.Horizontal() + | new Button("Previous") + | new Button("Next")); //NOTE: Parentheses are used to group the horizontal layout - THIS IS REQUIRED + +Grids: + +Layout.Grid() + .Columns(2) + .Rows(2) + .Gap(4) + .Padding(8) + | Text.Block("Cell 1") + | Text.Block("Cell 2") + ... + +Align values: TopLeft, TopCenter, TopRight, Left, Center, Right, BottomLeft, BottomCenter, BottomRight, Stretch + +[Layouts](https://docs.ivy.app/onboarding/concepts/layout.md) + +## Text + +The Text helper utility is used to create various semantic text elements. + +- Text.H1, Text.H2, : For headings. +- Text.Lead: For prominent introductory text. +- Text.P: For standard paragraphs. +- Text.Block: For block-level content (e.g., list items). +- Text.InlineCode: For displaying inline code snippets. + +Styling Modifiers: +.NoWrap(): +.Bold() +.Italic() +.StrikeThrough() +.Color(Colors) + +Layout.Vertical() + | Text.H1("Getting Started") + | Text.P("This is a paragraph of text.").NoWrap() + +[Text](https://docs.ivy.app/widgets/primitives/text-block.md) + +## Event Handling + +new Button("Click Me") + .Primary() + .HandleClick(() => { + count.Set(count.Value + 1); +}) + +new TextInput().Default() + .Value(text.Value) + .OnChange(text.Set) + .OnBlur(() => Console.WriteLine("Blurred")) + +## Hooks + +- Most hooks that you know from React are available in Ivy. They follow the same principles as in React. +- Hooks should only be called at the top level of a Build() method, not inside loops or conditions. + +### UseState + +var nameState = UseState("World"); +var iconsState = this.UseState); + +If you don't specify a value, default(T) is used. + +UseState hook returns a state object IState that provides: + +- .Value property to read the current state. +- .Set(newValue) method to update the state in UseEffect or in an event handler. + +### UseEffect + +void UseEffect(Action effect, params IEffectTriggers[] triggers) +void UseEffect(Func asyncEffect, params IEffectTriggers[] triggers) +void UseEffect(Func effectWithCleanup, params IEffectTriggers[] triggers) +void UseEffect(Func> asyncEffectWithCleanup, IEffectTriggers object[] triggers) + +- EffectTrigger.OnBuild() - runs after every build +- EffectTrigger.OnMount() - runs once when the view is first mounted +- EffectTrigger.OnStateChange(IState) - runs when the specified state changes + +- IState is automatically converted to EffectTrigger.OnStateChange +- If no triggers are provided, the effect trigger is assumed to be OnMount. + +### Other Hooks + +UseMemo +UseCallback +UseRef +UseContext +UseReducer +UseQuery +UseSignal + +## Inputs + +Ivy has several Input widgets for handling user input. There are rarely used directly - instead we use +extension methods on IState to bind state to inputs. + +var userNameState = UseState(""); +var input = userNameState.ToTextInput().Placeholder("Enter your name"); + +ToTextInput() +ToTextAreaInput() +ToPasswordInput() +ToNumberInput() +ToBoolInput() +ToSelectInput(IEnumerable) +ToCodeInput(Language) +ToColorInput() +ToDateTimeInput() +ToDateRangeInput() +ToFeedbackInput() + +Most inputs have extension methods for common configurations: +userNameState.ToTextInput().Required().MaxLength(50).Placeholder("Enter your name"); + +## Best Practices + +(Basically the same as React best practices) + +1. **Keep Views Pure** - Views should be pure functions of their props and state +2. **Use Hooks Correctly** - Call hooks at the top level, never in loops or conditions +3. **Minimize State** - Derive computed values instead of storing them +4. **Handle Loading States** - Always consider loading and error states +5. **Leverage Type Safety** - Use strongly-typed widgets and state +6. **Component Composition** - Build complex UIs from simple, reusable views + +## Further Reading + +[Forms](https://docs.ivy.app/onboarding/concepts/forms.md) +[DataTable](https://docs.ivy.app/widgets/advanced/data-table.md) +[Table](https://docs.ivy.app/widgets/common/table.md) +[Details](https://docs.ivy.app/widgets/common/details.md) - Display structured label-value pairs +[Services](https://docs.ivy.app/onboarding/concepts/services.md) +[Program.cs](https://docs.ivy.app/onboarding/concepts/program.md) +[Colors](https://docs.ivy.app/api-reference/ivy-shared/colors.md) +[Size](https://docs.ivy.app/api-reference/ivy-shared/size.md) +[Align](https://docs.ivy.app/api-reference/ivy-shared/align.md) +[UseAlert](https://docs.ivy.app/onboarding/concepts/alerts.md) +[RefreshTokens](https://docs.ivy.app/onboarding/concepts/refresh-tokens.md) +[Downloads](https://docs.ivy.app/onboarding/concepts/downloads.md) +[Uploads](https://docs.ivy.app/widgets/inputs/file.md) +[Icons](https://raw.githubusercontent.com/Ivy-Interactive/Ivy-Framework/refs/heads/main/src/Ivy/Shared/Icons.cs) diff --git a/Storage.Internal/Connections/StorageInternal/Category.cs b/Storage.Internal/Connections/StorageInternal/Category.cs new file mode 100644 index 0000000..d170cab --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Category.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Storage.Core.Connections.StorageInternal; + +public partial class Category +{ + [Key] + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public string? Description { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + + [InverseProperty("Category")] + public virtual ICollection Products { get; set; } = new List(); +} diff --git a/Storage.Internal/Connections/StorageInternal/GlobalUsings.cs b/Storage.Internal/Connections/StorageInternal/GlobalUsings.cs new file mode 100644 index 0000000..2acb509 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/GlobalUsings.cs @@ -0,0 +1 @@ +global using Storage.Core.Connections.StorageInternal; \ No newline at end of file diff --git a/Storage.Internal/Connections/StorageInternal/Image.cs b/Storage.Internal/Connections/StorageInternal/Image.cs new file mode 100644 index 0000000..8bab6ac --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Image.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Storage.Core.Connections.StorageInternal; + +public partial class Image +{ + [Key] + public int Id { get; set; } + + public string Src { get; set; } = null!; + + public string AltText { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + + [ForeignKey("ImageId")] + [InverseProperty("Images")] + public virtual ICollection Products { get; set; } = new List(); +} diff --git a/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.Designer.cs b/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.Designer.cs new file mode 100644 index 0000000..648f373 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.Designer.cs @@ -0,0 +1,176 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Storage.Core.Connections.StorageInternal; + +#nullable disable + +namespace Storage.Core.Connections.StorageInternal.Migrations +{ + [DbContext(typeof(StorageInternalContext))] + [Migration("20260124112746_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProductImage", b => + { + b.Property("ImageId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("ImageId", "ProductId"); + + b.HasIndex(new[] { "ProductId" }, "IX_ProductImages_ProductId"); + + b.ToTable("ProductImages", (string)null); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Src") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryCount") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("Price") + .HasColumnType("decimal(19, 4)"); + + b.Property("PurchasePrice") + .HasColumnType("decimal(19, 4)"); + + b.Property("Shelf") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CategoryId" }, "IX_Products_CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("ProductImage", b => + { + b.HasOne("Storage.Core.Connections.StorageInternal.Image", null) + .WithMany() + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Storage.Core.Connections.StorageInternal.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Product", b => + { + b.HasOne("Storage.Core.Connections.StorageInternal.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Category", b => + { + b.Navigation("Products"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.cs b/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.cs new file mode 100644 index 0000000..bae19b7 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Migrations/20260124112746_InitialCreate.cs @@ -0,0 +1,125 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Storage.Core.Connections.StorageInternal.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Images", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Src = table.Column(type: "nvarchar(max)", nullable: false), + AltText = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Images", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Price = table.Column(type: "decimal(19,4)", nullable: false), + OrderDate = table.Column(type: "datetime2", nullable: false), + PurchasePrice = table.Column(type: "decimal(19,4)", nullable: false), + InventoryCount = table.Column(type: "int", nullable: false), + Shelf = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + CategoryId = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + table.ForeignKey( + name: "FK_Products_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProductImages", + columns: table => new + { + ImageId = table.Column(type: "int", nullable: false), + ProductId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductImages", x => new { x.ImageId, x.ProductId }); + table.ForeignKey( + name: "FK_ProductImages_Images_ImageId", + column: x => x.ImageId, + principalTable: "Images", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductImages_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProductImages_ProductId", + table: "ProductImages", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_Products_CategoryId", + table: "Products", + column: "CategoryId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProductImages"); + + migrationBuilder.DropTable( + name: "Images"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/Storage.Internal/Connections/StorageInternal/Migrations/StorageInternalContextModelSnapshot.cs b/Storage.Internal/Connections/StorageInternal/Migrations/StorageInternalContextModelSnapshot.cs new file mode 100644 index 0000000..c383aee --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Migrations/StorageInternalContextModelSnapshot.cs @@ -0,0 +1,173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Storage.Core.Connections.StorageInternal; + +#nullable disable + +namespace Storage.Core.Connections.StorageInternal.Migrations +{ + [DbContext(typeof(StorageInternalContext))] + partial class StorageInternalContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProductImage", b => + { + b.Property("ImageId") + .HasColumnType("int"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.HasKey("ImageId", "ProductId"); + + b.HasIndex(new[] { "ProductId" }, "IX_ProductImages_ProductId"); + + b.ToTable("ProductImages", (string)null); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AltText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Src") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryCount") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("Price") + .HasColumnType("decimal(19, 4)"); + + b.Property("PurchasePrice") + .HasColumnType("decimal(19, 4)"); + + b.Property("Shelf") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CategoryId" }, "IX_Products_CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("ProductImage", b => + { + b.HasOne("Storage.Core.Connections.StorageInternal.Image", null) + .WithMany() + .HasForeignKey("ImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Storage.Core.Connections.StorageInternal.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Product", b => + { + b.HasOne("Storage.Core.Connections.StorageInternal.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Storage.Core.Connections.StorageInternal.Category", b => + { + b.Navigation("Products"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Storage.Internal/Connections/StorageInternal/Product.cs b/Storage.Internal/Connections/StorageInternal/Product.cs new file mode 100644 index 0000000..656a94a --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/Product.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Storage.Core.Connections.StorageInternal; + +[Index("CategoryId", Name = "IX_Products_CategoryId")] +public partial class Product +{ + [Key] + public int Id { get; set; } + + public string Name { get; set; } = null!; + + [Column(TypeName = "decimal(19, 4)")] + public decimal Price { get; set; } + + public DateTime OrderDate { get; set; } + + [Column(TypeName = "decimal(19, 4)")] + public decimal PurchasePrice { get; set; } + + public int InventoryCount { get; set; } + + public string Shelf { get; set; } = null!; + + public string? Description { get; set; } + + public int CategoryId { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + + [ForeignKey("CategoryId")] + [InverseProperty("Products")] + public virtual Category Category { get; set; } = null!; + + [ForeignKey("ProductId")] + [InverseProperty("Products")] + public virtual ICollection Images { get; set; } = new List(); +} diff --git a/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs b/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs new file mode 100644 index 0000000..481f049 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs @@ -0,0 +1,47 @@ +using Ivy.Connections; +using Ivy.Services; + +namespace Storage.Core.Connections.StorageInternal; + +public class StorageInternalConnection : IConnection, IHaveSecrets +{ + public string GetContext(string connectionPath) + { + var connectionFile = nameof(StorageInternalConnection) + ".cs"; + var contextFactoryFile = nameof(StorageInternalContextFactory) + ".cs"; + var files = System.IO.Directory.GetFiles(connectionPath, "*.*", System.IO.SearchOption.TopDirectoryOnly) + .Where(f => !f.EndsWith(connectionFile) && !f.EndsWith(contextFactoryFile) && !f.EndsWith("EfmigrationsLock.cs")) + .Select(System.IO.File.ReadAllText) + .ToArray(); + return string.Join(System.Environment.NewLine, files); + } + + public string GetName() => nameof(StorageInternal); + + public string GetNamespace() => typeof(StorageInternalConnection).Namespace; + + public string GetConnectionType() => "EntityFramework.SqlServer"; + + public ConnectionEntity[] GetEntities() + { + return typeof(StorageInternalContext) + .GetProperties() + .Where(e => e.PropertyType.IsGenericType && e.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Where(e => e.PropertyType.GenericTypeArguments[0].Name != "EfmigrationsLock") + .Select(e => new ConnectionEntity(e.PropertyType.GenericTypeArguments[0].Name, e.Name)) + .ToArray(); + } + + public void RegisterServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public Ivy.Services.Secret[] GetSecrets() + { + return + [ + new("ConnectionStrings:StorageInternal") + ]; + } +} diff --git a/Storage.Internal/Connections/StorageInternal/StorageInternalContext.cs b/Storage.Internal/Connections/StorageInternal/StorageInternalContext.cs new file mode 100644 index 0000000..071c1f0 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/StorageInternalContext.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace Storage.Core.Connections.StorageInternal; + +public partial class StorageInternalContext : DbContext +{ + public StorageInternalContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Categories { get; set; } + + public virtual DbSet Images { get; set; } + + public virtual DbSet Products { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasMany(d => d.Products).WithMany(p => p.Images) + .UsingEntity>( + "ProductImage", + r => r.HasOne().WithMany().HasForeignKey("ProductId"), + l => l.HasOne().WithMany().HasForeignKey("ImageId"), + j => + { + j.HasKey("ImageId", "ProductId"); + j.ToTable("ProductImages"); + j.HasIndex(new[] { "ProductId" }, "IX_ProductImages_ProductId"); + }); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/Storage.Internal/Connections/StorageInternal/StorageInternalContextFactory.cs b/Storage.Internal/Connections/StorageInternal/StorageInternalContextFactory.cs new file mode 100644 index 0000000..a7e172b --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/StorageInternalContextFactory.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace Storage.Core.Connections.StorageInternal; + +public class StorageInternalContextFactory(ServerArgs args) : IDbContextFactory +{ + public StorageInternalContext CreateDbContext() + { + var configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + + var connectionString = configuration.GetConnectionString("StorageInternal"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("Database connection string 'StorageInternal' is not set."); + } + + optionsBuilder.UseSqlServer(connectionString, conf => + { + conf.EnableRetryOnFailure(5, TimeSpan.FromSeconds(2), null); + }); + + if (args.Verbose) + { + optionsBuilder + .EnableSensitiveDataLogging() + .LogTo(Console.WriteLine, LogLevel.Information); + } + + return new StorageInternalContext(optionsBuilder.Options); + } +} diff --git a/Storage.Internal/Connections/StorageInternal/StorageInternalDesignTimeContextFactory.cs b/Storage.Internal/Connections/StorageInternal/StorageInternalDesignTimeContextFactory.cs new file mode 100644 index 0000000..04cf237 --- /dev/null +++ b/Storage.Internal/Connections/StorageInternal/StorageInternalDesignTimeContextFactory.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore.Design; + +namespace Storage.Core.Connections.StorageInternal; + +public class StorageInternalDesignTimeContextFactory : IDesignTimeDbContextFactory +{ + public StorageInternalContext CreateDbContext(string[] args) + { + var serverArgs = new ServerArgs { Verbose = false }; + var contextFactory = new StorageInternalContextFactory(serverArgs); + return contextFactory.CreateDbContext(); + } +} diff --git a/Storage.Internal/GlobalUsings.cs b/Storage.Internal/GlobalUsings.cs new file mode 100644 index 0000000..bd02411 --- /dev/null +++ b/Storage.Internal/GlobalUsings.cs @@ -0,0 +1,31 @@ +global using Ivy.Apps; +global using Ivy.Auth; +global using Ivy.Chrome; +global using Ivy.Client; +global using Ivy.Core.Hooks; +global using Ivy.Core; +global using Ivy.Helpers; +global using Ivy.Hooks; +global using Ivy.Services; +global using Ivy.Shared; +global using Ivy.Views.Alerts; +global using Ivy.Views.Blades; +global using Ivy.Views.Builders; +global using Ivy.Views.Charts; +global using Ivy.Views.Dashboards; +global using Ivy.Views.DataTables; +global using Ivy.Views.Forms; +global using Ivy.Views.Tables; +global using Ivy.Views; +global using Ivy.Widgets.Inputs; +global using Ivy; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using System.Collections.Immutable; +global using System.ComponentModel.DataAnnotations; +global using System.Globalization; +global using System.Reactive.Linq; + +namespace Storage.Internal; diff --git a/Storage.Internal/README.md b/Storage.Internal/README.md new file mode 100644 index 0000000..7942dbd --- /dev/null +++ b/Storage.Internal/README.md @@ -0,0 +1,20 @@ +# Storage.Internal + +Web application created using [Ivy](https://github.com/Ivy-Interactive/Ivy). + +Ivy is a web framework for building interactive web applications using C# and .NET. + +> [!NOTE] +> This project includes an `AGENTS.md` file which provides context for AI agents (like GitHub Copilot, Cursor, or Claude) to better understand the Ivy framework and your project's structure. + +## Run + +```bash +dotnet watch +``` + +## Deploy + +```bash +ivy deploy +``` \ No newline at end of file diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index 4c23d0f..006fd1c 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -5,10 +5,22 @@ net10.0 enable enable + Storage.Core + 9a55a323-6603-4568-9204-c99d7d6468d2 + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + From e3d4d6a5f4dd1388817e210ac1ae2ea247239dba Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Mon, 26 Jan 2026 09:32:48 +0100 Subject: [PATCH 04/17] add db connection --- .../Apps/Publishing/ProductsPublishingApp.cs | 21 +++++++++++++++++-- .../StorageInternalConnection.cs | 2 +- Storage.Internal/Program.cs | 6 +++++- .../Services/PublishingService.cs | 20 ++++++++++++++++++ Storage.Internal/Storage.Internal.csproj | 2 +- 5 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 Storage.Internal/Services/PublishingService.cs diff --git a/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs index 274794a..d46ed4c 100644 --- a/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs +++ b/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs @@ -2,16 +2,33 @@ using Ivy; using Ivy.Shared; using Ivy.Views; +using Storage.Core.Connections; namespace Storage.Internal.Apps.Publishing; [App(icon: Icons.ShoppingBasket, title: "Products Publishing")] public class ProductsPublishingApp : ViewBase { - public override object? Build() + public override async Task Build() { + var publishingService = UseService(); + var products = publishingService.AllProducts; + return new StackLayout([ - Text.H1("Products Publishing") + Text.H1("Products Publishing"), + products.ToTable() + .Clear() + .Add(p => p.Name) + .Add(p => p.Category) + .Header(p => p.Category, "Category") + .Add(p => p.PurchasePrice) + .Header(p => p.PurchasePrice, "Purchase Price") + .Add(p => p.Price) + .Header(p => p.Price, "Sales Price") + .Add(p => p.OrderDate) + .Header(p => p.OrderDate, "Order Date") + .Add(p => p.Shelf) + .Header(p => p.Shelf, "Stock Location") ]); } } diff --git a/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs b/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs index 481f049..d1cad38 100644 --- a/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs +++ b/Storage.Internal/Connections/StorageInternal/StorageInternalConnection.cs @@ -18,7 +18,7 @@ public string GetContext(string connectionPath) public string GetName() => nameof(StorageInternal); - public string GetNamespace() => typeof(StorageInternalConnection).Namespace; + public string GetNamespace() => typeof(StorageInternalConnection).Namespace ?? throw new Exception("Could not read namespace"); public string GetConnectionType() => "EntityFramework.SqlServer"; diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 550533e..85eef82 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -16,9 +16,13 @@ var server = new Server(); server.UseHotReload(); server.AddAppsFromAssembly(); -// server.UseDefaultApp(typeof(Demo)); +server.AddConnectionsFromAssembly(); server.UseChrome(() => new DefaultSidebarChrome(chromeSettings)); +var storageInternalConnection = new StorageInternalConnection(); +storageInternalConnection.RegisterServices(server.Services); +server.Services.AddScoped(); + await server.RunAsync(); diff --git a/Storage.Internal/Services/PublishingService.cs b/Storage.Internal/Services/PublishingService.cs new file mode 100644 index 0000000..19ab844 --- /dev/null +++ b/Storage.Internal/Services/PublishingService.cs @@ -0,0 +1,20 @@ +using System; +using Ivy.Connections; + +namespace Storage.Core.Connections.StorageInternal; + +public interface IPublishingService +{ + IQueryable AllProducts { get; } +} +public class PublishingService : IPublishingService +{ + private readonly StorageInternalContext _context; + public PublishingService( + StorageInternalContextFactory contextFactory + ) + { + _context = contextFactory.CreateDbContext(); + } + public IQueryable AllProducts => _context.Products.Include(p => p.Category); +} diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index 006fd1c..e3cfb1b 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From fadff9c2e1b8778ccb845043a56eccb705ed08cd Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Mon, 26 Jan 2026 15:03:58 +0100 Subject: [PATCH 05/17] add Products and Categories sub-directories --- .../{ => Categories}/CategoriesPublishing.cs | 0 .../Products/ProductsPublishingApp.cs | 34 +++++++++++++++++++ .../Products/ProductsPublishingListItem.cs | 28 +++++++++++++++ .../Products/ProductsPublishingTable.cs | 24 +++++++++++++ .../Apps/Publishing/ProductsPublishingApp.cs | 34 ------------------- 5 files changed, 86 insertions(+), 34 deletions(-) rename Storage.Internal/Apps/Publishing/{ => Categories}/CategoriesPublishing.cs (100%) create mode 100644 Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/ProductsPublishingListItem.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs delete mode 100644 Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs diff --git a/Storage.Internal/Apps/Publishing/CategoriesPublishing.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs similarity index 100% rename from Storage.Internal/Apps/Publishing/CategoriesPublishing.cs rename to Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs new file mode 100644 index 0000000..d2abb3e --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs @@ -0,0 +1,34 @@ +using System; +using Ivy; +using Ivy.Shared; +using Ivy.Views; +using Storage.Core.Apps.Publishing; +using Storage.Core.Connections; + +namespace Storage.Internal.Apps.Publishing; + +[App(icon: Icons.ShoppingBasket, title: "Products Publishing")] +public class ProductsPublishingApp : ViewBase +{ + public override async Task Build() + { + var publishingService = UseService(); + var products = publishingService.AllProducts + .Select(p => new ProductsPublishingListItem + { + Id = p.Id, + Name = p.Name, + Category = p.Category.Name, + Price = p.Price, + PurchasePrice = p.PurchasePrice, + OrderDate = p.OrderDate, + Count = p.InventoryCount, + Description = p.Description ?? "", + }); + + return new StackLayout([ + Text.H1("Products Publishing"), + new ProductsPublishingTable(products) + ]); + } +} diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingListItem.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingListItem.cs new file mode 100644 index 0000000..530d1d2 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingListItem.cs @@ -0,0 +1,28 @@ +using System; + +namespace Storage.Core.Apps.Publishing; + +public class ProductsPublishingListItem +{ + public int Id { get; init; } + + public required string Name { get; init; } + + [DataType(DataType.Currency)] + public decimal Price { get; init; } + + // [DataType(DataType.Currency)] + [Display(Name = "Purchase Price")] + [DisplayFormat(DataFormatString="{0:C0}")] + public decimal PurchasePrice { get; init; } + + [Display(Name = "Order Date")] + [DataType(DataType.Date)] + public required DateTime OrderDate { get; init; } + + public required string Category { get; init; } + + public int Count { get; init; } + + public string Description { get; init; } = string.Empty; +} diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs new file mode 100644 index 0000000..1aca408 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs @@ -0,0 +1,24 @@ +using System; + +namespace Storage.Core.Apps.Publishing; + +public class ProductsPublishingTable(IQueryable products) : ViewBase +{ + private IQueryable _products { get; init; } = products; + public override object? Build() + { + return _products.ToTable() + .Width(Size.Full()) + .Clear() + .Add(p => p.Name) + .Add(p => p.Category) + .Header(p => p.Category, "Category") + .Add(p => p.PurchasePrice) + .Header(p => p.PurchasePrice, "Purchase Price") + .Add(p => p.Price) + .Header(p => p.Price, "Sales Price") + .Add(p => p.OrderDate) + .Header(p => p.OrderDate, "Order Date") + .Add(p => p.Description); + } +} diff --git a/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs deleted file mode 100644 index d46ed4c..0000000 --- a/Storage.Internal/Apps/Publishing/ProductsPublishingApp.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Ivy; -using Ivy.Shared; -using Ivy.Views; -using Storage.Core.Connections; - -namespace Storage.Internal.Apps.Publishing; - -[App(icon: Icons.ShoppingBasket, title: "Products Publishing")] -public class ProductsPublishingApp : ViewBase -{ - public override async Task Build() - { - var publishingService = UseService(); - var products = publishingService.AllProducts; - - return new StackLayout([ - Text.H1("Products Publishing"), - products.ToTable() - .Clear() - .Add(p => p.Name) - .Add(p => p.Category) - .Header(p => p.Category, "Category") - .Add(p => p.PurchasePrice) - .Header(p => p.PurchasePrice, "Purchase Price") - .Add(p => p.Price) - .Header(p => p.Price, "Sales Price") - .Add(p => p.OrderDate) - .Header(p => p.OrderDate, "Order Date") - .Add(p => p.Shelf) - .Header(p => p.Shelf, "Stock Location") - ]); - } -} From 348099c075e5ab8f656399f6597facdaaab762c2 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Mon, 26 Jan 2026 15:23:34 +0100 Subject: [PATCH 06/17] add CategoriesPublishingService --- .../ICategoriesPublishingService.cs | 8 ++++++++ .../Products/IProductsPublishingService.cs | 8 ++++++++ .../Products/ProductsPublishingApp.cs | 9 +++------ Storage.Internal/Program.cs | 9 +++++---- .../Services/CategoriesPublishingService.cs | 12 +++++++++++ .../Services/ProductsPublishingService.cs | 14 +++++++++++++ .../Services/PublishingService.cs | 20 ------------------- 7 files changed, 50 insertions(+), 30 deletions(-) create mode 100644 Storage.Internal/Apps/Publishing/Categories/ICategoriesPublishingService.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs create mode 100644 Storage.Internal/Services/CategoriesPublishingService.cs create mode 100644 Storage.Internal/Services/ProductsPublishingService.cs delete mode 100644 Storage.Internal/Services/PublishingService.cs diff --git a/Storage.Internal/Apps/Publishing/Categories/ICategoriesPublishingService.cs b/Storage.Internal/Apps/Publishing/Categories/ICategoriesPublishingService.cs new file mode 100644 index 0000000..3d124be --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Categories/ICategoriesPublishingService.cs @@ -0,0 +1,8 @@ +using System; + +namespace Storage.Core.Apps.Publishing.Categories; + +public interface ICategoriesPublishingService +{ + IQueryable AllCategories { get; } +} diff --git a/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs b/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs new file mode 100644 index 0000000..087e943 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs @@ -0,0 +1,8 @@ +using System; + +namespace Storage.Core.Apps.Publishing.products; + +public interface IProductsPublishingService +{ + IQueryable AllProducts { get; } +} \ No newline at end of file diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs index d2abb3e..979f3ed 100644 --- a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs @@ -1,9 +1,5 @@ -using System; -using Ivy; -using Ivy.Shared; -using Ivy.Views; using Storage.Core.Apps.Publishing; -using Storage.Core.Connections; +using Storage.Core.Apps.Publishing.products; namespace Storage.Internal.Apps.Publishing; @@ -12,8 +8,9 @@ public class ProductsPublishingApp : ViewBase { public override async Task Build() { - var publishingService = UseService(); + var publishingService = UseService(); var products = publishingService.AllProducts + .Include(p => p.Category) .Select(p => new ProductsPublishingListItem { Id = p.Id, diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 85eef82..791d3f3 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -1,6 +1,6 @@ -using Ivy; -using Ivy.Chrome; -using Ivy.Views; +using Storage.Core.Apps.Publishing.Categories; +using Storage.Core.Apps.Publishing.products; +using Storage.Core.Services; using Storage.Internal; using Storage.Internal.Apps.Publishing; @@ -21,7 +21,8 @@ var storageInternalConnection = new StorageInternalConnection(); storageInternalConnection.RegisterServices(server.Services); -server.Services.AddScoped(); +server.Services.AddScoped(); +server.Services.AddScoped(); await server.RunAsync(); diff --git a/Storage.Internal/Services/CategoriesPublishingService.cs b/Storage.Internal/Services/CategoriesPublishingService.cs new file mode 100644 index 0000000..fadbfa3 --- /dev/null +++ b/Storage.Internal/Services/CategoriesPublishingService.cs @@ -0,0 +1,12 @@ +using System; +using Storage.Core.Apps.Publishing.Categories; + +namespace Storage.Core.Services; + +public class CategoriesPublishingService( + StorageInternalContextFactory contextFactory +) : ICategoriesPublishingService +{ + private readonly StorageInternalContext _context = contextFactory.CreateDbContext(); + public IQueryable AllCategories => _context.Categories; +} diff --git a/Storage.Internal/Services/ProductsPublishingService.cs b/Storage.Internal/Services/ProductsPublishingService.cs new file mode 100644 index 0000000..1ba5854 --- /dev/null +++ b/Storage.Internal/Services/ProductsPublishingService.cs @@ -0,0 +1,14 @@ +using System; +using Ivy.Connections; +using Storage.Core.Apps.Publishing.products; + +namespace Storage.Core.Connections.StorageInternal; + +public class ProductsPublishingService( + StorageInternalContextFactory contextFactory + ) : IProductsPublishingService +{ + private readonly StorageInternalContext _context = contextFactory.CreateDbContext(); + + public IQueryable AllProducts => _context.Products; +} diff --git a/Storage.Internal/Services/PublishingService.cs b/Storage.Internal/Services/PublishingService.cs deleted file mode 100644 index 19ab844..0000000 --- a/Storage.Internal/Services/PublishingService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Ivy.Connections; - -namespace Storage.Core.Connections.StorageInternal; - -public interface IPublishingService -{ - IQueryable AllProducts { get; } -} -public class PublishingService : IPublishingService -{ - private readonly StorageInternalContext _context; - public PublishingService( - StorageInternalContextFactory contextFactory - ) - { - _context = contextFactory.CreateDbContext(); - } - public IQueryable AllProducts => _context.Products.Include(p => p.Category); -} From 06971c5d1113902c7c18ba9d799098db0e81eade Mon Sep 17 00:00:00 2001 From: "Christopher Clemons (Windows)" Date: Mon, 26 Jan 2026 16:04:11 +0100 Subject: [PATCH 07/17] add categories publishing list --- .../Categories/CategoriesPublishing.cs | 17 ----------- .../Categories/CategoriesPublishingApp.cs | 29 +++++++++++++++++++ .../CategoriesPublishingListItem.cs | 18 ++++++++++++ .../Categories/CategoriesPublishingTable.cs | 25 ++++++++++++++++ .../Products/ProductsPublishingTable.cs | 17 +++++++---- 5 files changed, 84 insertions(+), 22 deletions(-) delete mode 100644 Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs create mode 100644 Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingApp.cs create mode 100644 Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs create mode 100644 Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingTable.cs diff --git a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs deleted file mode 100644 index 2f250a1..0000000 --- a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishing.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using Ivy; -using Ivy.Shared; -using Ivy.Views; - -namespace Storage.Internal.Apps.Publishing; - -[App(icon: Icons.Bookmark, title: "Categories Publishing")] -public class CategoriesPublishing : ViewBase -{ - public override object? Build() - { - return new StackLayout([ - Text.H1("Categories Publishing") - ]); - } -} diff --git a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingApp.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingApp.cs new file mode 100644 index 0000000..67a38cb --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingApp.cs @@ -0,0 +1,29 @@ +using System; +using Ivy; +using Ivy.Shared; +using Ivy.Views; +using Storage.Core.Apps.Publishing.Categories; + +namespace Storage.Internal.Apps.Publishing; + +[App(icon: Icons.Bookmark, title: "Categories Publishing")] +public class CategoriesPublishingApp : ViewBase +{ + public override object? Build() + { + var categoriesService = UseService(); + IQueryable categories = categoriesService.AllCategories + .Select(c => new CategoriesPublishingListItem + { + Id = c.Id, + Name = c.Name, + ProductCount = c.Products.Count, + Description = c.Description, + }); + + return new StackLayout([ + Text.H1("Categories Publishing"), + new CategoriesPublishingTable(categories) + ]); + } +} diff --git a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs new file mode 100644 index 0000000..db9ca8e --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Storage.Core.Apps.Publishing.Categories +{ + public class CategoriesPublishingListItem + { + public int Id { get; set; } = default!; + + [Display(Name = "Products")] + public int ProductCount { get; set; } + + public string Name { get; set; } = default!; + + public string Description { get; set; } = string.Empty; + } +} diff --git a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingTable.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingTable.cs new file mode 100644 index 0000000..a895b9a --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingTable.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Storage.Core.Apps.Publishing.Categories +{ + public class CategoriesPublishingTable(IQueryable categories) : ViewBase + { + private IQueryable _categories { get; init; } = categories; + + public override object? Build() + { + return _categories.ToTable() + .Width(Size.Full()) + .Clear() + .Add(c => c.Name) + .Add(c => c.ProductCount) + .Add(c => c.Description) + .Order( + c => c.Name, + c => c.ProductCount, + c => c.Description); + } + } +} diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs index 1aca408..950cac7 100644 --- a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingTable.cs @@ -12,13 +12,20 @@ public class ProductsPublishingTable(IQueryable prod .Clear() .Add(p => p.Name) .Add(p => p.Category) - .Header(p => p.Category, "Category") .Add(p => p.PurchasePrice) - .Header(p => p.PurchasePrice, "Purchase Price") .Add(p => p.Price) - .Header(p => p.Price, "Sales Price") .Add(p => p.OrderDate) - .Header(p => p.OrderDate, "Order Date") - .Add(p => p.Description); + .Add(p => p.Count) + .Header(p => p.Count, "Inv. Count") + .Add(p => p.Description) + .Order( + p => p.Name, + p => p.Category, + p => p.Price, + p => p.PurchasePrice, + p => p.OrderDate, + p => p.Count, + p => p.Description + ); } } From 91aed2ccd9c13cb6a70f372a95f2181cc3f4ec8c Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Mon, 26 Jan 2026 17:19:16 +0100 Subject: [PATCH 08/17] run `ivy app create` based on entity `Connections/StorageInternal/Product` Moved prompt output to `Apps/Publishing/Products` with minor fixes --- Storage.Internal/.ivy/session.ldjson | 1 + .../Apps/Publishing/Products/ProductsApp.cs | 12 ++ .../Views/ProductsApp.ProductCreateDialog.cs | 103 +++++++++++++++++ .../Views/ProductsApp.ProductDetailsBlade.cs | 87 ++++++++++++++ .../Views/ProductsApp.ProductEditSheet.cs | 79 +++++++++++++ .../Views/ProductsApp.ProductListBlade.cs | 108 ++++++++++++++++++ Storage.Internal/Storage.Internal.csproj | 4 + 7 files changed, 394 insertions(+) create mode 100644 Storage.Internal/.ivy/session.ldjson create mode 100644 Storage.Internal/Apps/Publishing/Products/ProductsApp.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs diff --git a/Storage.Internal/.ivy/session.ldjson b/Storage.Internal/.ivy/session.ldjson new file mode 100644 index 0000000..999ea5e --- /dev/null +++ b/Storage.Internal/.ivy/session.ldjson @@ -0,0 +1 @@ +{"sessionId":"c6667d1f-0f4b-47ad-ad55-de07e26ce561","command":"AppCreateCommand","timestamp":"2026-01-26T15:16:27.1892180\u002B00:00","ivyVersion":"1.2.13\u002Bd38a7abbe6a425d73ac6142b09dacf4195c4dc90","operatingSystem":"macOS 15.1.1 (Arm64)","args":{"prompt":null,"group":null,"icon":null,"connections":null,"fromEntities":false,"entity":null,"timeoutSeconds":360,"timeout":"00:06:00","skipBuild":false,"skipDebug":false,"sessionId":null,"modelId":null,"modelDisableCache":false,"ignoreGit":false,"verbose":false,"nonInteractive":false,"staging":false,"disableTelemetry":false,"authServer":"https://ivy.app","agentServer":"https://agent.ivy.app","apiServer":"https://api.ivy.app","silent":false}} diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsApp.cs b/Storage.Internal/Apps/Publishing/Products/ProductsApp.cs new file mode 100644 index 0000000..fe96d46 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ProductsApp.cs @@ -0,0 +1,12 @@ +using Storage.Core.Apps.Views; + +namespace Storage.Core.Apps; + +[App(icon: Icons.PackagePlus, path: ["Publishing"])] +public class ProductsApp : ViewBase +{ + public override object? Build() + { + return this.UseBlades(() => new ProductListBlade(), "Search"); + } +} diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs new file mode 100644 index 0000000..60fa5d1 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs @@ -0,0 +1,103 @@ +namespace Storage.Core.Apps.Views; + +public class ProductCreateDialog(IState isOpen, RefreshToken refreshToken) : ViewBase +{ + private record ProductCreateRequest + { + [Required] + public string Name { get; init; } = ""; + + [Required] + public decimal? Price { get; init; } = null; + + [Required] + public decimal? PurchasePrice { get; init; } = null; + + [Required] + public int? InventoryCount { get; init; } = null; + + [Required] + public string Shelf { get; init; } = ""; + + public string? Description { get; init; } = null; + + [Required] + public int? CategoryId { get; init; } = null; + } + + public override object? Build() + { + var factory = UseService(); + var product = UseState(() => new ProductCreateRequest()); + + return product + .ToForm() + .Builder(e => e.Price, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.CategoryId, e => e.ToAsyncSelectInput(UseCategorySearch, UseCategoryLookup, placeholder: "Select Category")) + .HandleSubmit(OnSubmit) + .ToDialog(isOpen, title: "Create Product", submitTitle: "Create"); + + async Task OnSubmit(ProductCreateRequest request) + { + var productId = await CreateProductAsync(factory, request); + refreshToken.Refresh(productId); + } + } + + private async Task CreateProductAsync(StorageInternalContextFactory factory, ProductCreateRequest request) + { + await using var db = factory.CreateDbContext(); + + var product = new Product + { + Name = request.Name, + Price = request.Price!.Value, + PurchasePrice = request.PurchasePrice!.Value, + InventoryCount = request.InventoryCount!.Value, + Shelf = request.Shelf, + Description = request.Description, + CategoryId = request.CategoryId!.Value, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + db.Products.Add(product); + await db.SaveChangesAsync(); + + return product.Id; + } + + private static QueryResult[]> UseCategorySearch(IViewContext context, string query) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseCategorySearch), query), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + return (await db.Categories + .Where(e => e.Name.Contains(query)) + .Select(e => new { e.Id, e.Name }) + .Take(50) + .ToArrayAsync(ct)) + .Select(e => new Option(e.Name, e.Id)) + .ToArray(); + }); + } + + private static QueryResult?> UseCategoryLookup(IViewContext context, int? id) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseCategoryLookup), id), + fetcher: async ct => + { + if (id == null) return null; + await using var db = factory.CreateDbContext(); + var category = await db.Categories.FirstOrDefaultAsync(e => e.Id == id, ct); + if (category == null) return null; + return new Option(category.Name, category.Id); + }); + } +} \ No newline at end of file diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs new file mode 100644 index 0000000..10166db --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs @@ -0,0 +1,87 @@ +namespace Storage.Core.Apps.Views; + +public class ProductDetailsBlade(int productId) : ViewBase +{ + public override object? Build() + { + var factory = UseService(); + var blades = UseContext(); + var queryService = UseService(); + + var productQuery = UseQuery( + key: (nameof(ProductDetailsBlade), productId), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + return await db.Products + .Include(e => e.Category) + .Include(e => e.Images) + .SingleOrDefaultAsync(e => e.Id == productId, ct); + }, + tags: [(typeof(Product), productId)] + ); + + if (productQuery.Loading) return Skeleton.Card(); + + if (productQuery.Value == null) + { + return new Callout($"Product '{productId}' not found. It may have been deleted.") + .Variant(CalloutVariant.Warning); + } + + var productValue = productQuery.Value; + + var deleteBtn = new Button("Delete", onClick: async _ => + { + blades.Pop(refresh: true); + await DeleteAsync(factory); + queryService.RevalidateByTag(typeof(Product[])); + }) + .Variant(ButtonVariant.Destructive) + .Icon(Icons.Trash) + .WithConfirm("Are you sure you want to delete this product?", "Delete Product"); + + var editBtn = new Button("Edit") + .Variant(ButtonVariant.Outline) + .Icon(Icons.Pencil) + .Width(Size.Grow()) + .ToTrigger((isOpen) => new ProductEditSheet(isOpen, productId)); + + var detailsCard = new Card( + content: new + { + Id = productValue.Id, + Name = productValue.Name, + Price = productValue.Price, + PurchasePrice = productValue.PurchasePrice, + InventoryCount = productValue.InventoryCount, + Shelf = productValue.Shelf, + Category = productValue.Category.Name, + Description = productValue.Description, + Images = productValue.Images.Select(i => i.Src).ToList() + } + .ToDetails() + .MultiLine(e => e.Description ?? "") + .RemoveEmpty() + .Builder(e => e.Id, e => e.CopyToClipboard()), + footer: Layout.Horizontal().Gap(2).Align(Align.Right) + | deleteBtn + | editBtn + ).Title("Product Details").Width(Size.Units(100)); + + return new Fragment() + | new BladeHeader(Text.Literal(productValue.Name)) + | (Layout.Vertical() | detailsCard); + } + + private async Task DeleteAsync(StorageInternalContextFactory dbFactory) + { + await using var db = dbFactory.CreateDbContext(); + var product = await db.Products.FirstOrDefaultAsync(e => e.Id == productId); + if (product != null) + { + db.Products.Remove(product); + await db.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs new file mode 100644 index 0000000..3c9c4ec --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs @@ -0,0 +1,79 @@ +namespace Storage.Core.Apps.Views; + +public class ProductEditSheet(IState isOpen, int productId) : ViewBase +{ + public override object? Build() + { + var factory = UseService(); + var queryService = UseService(); + + var productQuery = UseQuery( + key: (typeof(Product), productId), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + return await db.Products.FirstAsync(e => e.Id == productId, ct); + }, + tags: [(typeof(Product), productId)] + ); + + if (productQuery.Loading || productQuery.Value == null) + return Skeleton.Form().ToSheet(isOpen, "Edit Product"); + + return productQuery.Value + .ToForm() + .Builder(e => e.Name, e => e.ToTextInput()) + .Builder(e => e.Price, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.InventoryCount, e => e.ToNumberInput()) + .Builder(e => e.Shelf, e => e.ToTextInput()) + .Builder(e => e.Description, e => e.ToTextAreaInput()) + .Builder(e => e.CategoryId, e => e.ToAsyncSelectInput(UseCategorySearch, UseCategoryLookup, placeholder: "Select Category")) + .Remove(e => e.Id, e => e.CreatedAt, e => e.UpdatedAt) + .HandleSubmit(OnSubmit) + .ToSheet(isOpen, "Edit Product"); + + async Task OnSubmit(Product? request) + { + if (request == null) return; + await using var db = factory.CreateDbContext(); + request.UpdatedAt = DateTime.UtcNow; + db.Products.Update(request); + await db.SaveChangesAsync(); + queryService.RevalidateByTag((typeof(Product), productId)); + } + } + + private static QueryResult[]> UseCategorySearch(IViewContext context, string query) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseCategorySearch), query), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + return (await db.Categories + .Where(e => e.Name.Contains(query)) + .Select(e => new { e.Id, e.Name }) + .Take(50) + .ToArrayAsync(ct)) + .Select(e => new Option(e.Name, e.Id)) + .ToArray(); + }); + } + + private static QueryResult?> UseCategoryLookup(IViewContext context, int? id) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseCategoryLookup), id), + fetcher: async ct => + { + if (id == null) return null; + await using var db = factory.CreateDbContext(); + var category = await db.Categories.FirstOrDefaultAsync(e => e.Id == id, ct); + if (category == null) return null; + return new Option(category.Name, category.Id); + }); + } +} \ No newline at end of file diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs new file mode 100644 index 0000000..88957a0 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs @@ -0,0 +1,108 @@ +namespace Storage.Core.Apps.Views; + +public class ProductListBlade : ViewBase +{ + private record ProductListRecord(int Id, string Name, string? CategoryName); + + public override object? Build() + { + var blades = UseContext(); + var refreshToken = UseRefreshToken(); + + var filter = UseState(""); + + var productsQuery = UseProductListRecords(Context, filter.Value); + + UseEffect(() => + { + if (refreshToken.ReturnValue is int productId) + { + blades.Pop(this, true); + productsQuery.Mutator.Revalidate(); + blades.Push(this, new ProductDetailsBlade(productId)); + } + }, [refreshToken]); + + var onItemClicked = new Action>(e => + { + var product = (ProductListRecord)e.Sender.Tag!; + blades.Push(this, new ProductDetailsBlade(product.Id), product.Name); + }); + + object CreateItem(ProductListRecord listRecord) => new FuncView(context => + { + var itemQuery = UseProductListRecord(context, listRecord); + if (itemQuery.Loading || itemQuery.Value == null) + { + return new ListItem(); + } + var record = itemQuery.Value; + return new ListItem(title: record.Name, subtitle: record.CategoryName, onClick: onItemClicked, tag: record); + }); + + var createBtn = Icons.Plus.ToButton(_ => + { + blades.Pop(this); + }).Ghost().Tooltip("Create Product").ToTrigger((isOpen) => new ProductCreateDialog(isOpen, refreshToken)); + + var items = (productsQuery.Value ?? []).Select(CreateItem); + + var header = Layout.Horizontal().Gap(1) + | filter.ToSearchInput().Placeholder("Search").Width(Size.Grow()) + | createBtn; + + return new Fragment() + | new BladeHeader(header) + | (productsQuery.Value == null ? Text.Muted("Loading...") : new List(items)); + } + + private static QueryResult UseProductListRecords(IViewContext context, string filter) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseProductListRecords), filter), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + + var linq = db.Products.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(filter)) + { + filter = filter.Trim(); + linq = linq.Where(e => e.Name.Contains(filter)); + } + + return await linq + .OrderByDescending(e => e.CreatedAt) + .Take(50) + .Select(e => new ProductListRecord(e.Id, e.Name, e.Category != null ? e.Category.Name : null)) + .ToArrayAsync(ct); + }, + tags: [typeof(Product[])], + options: new QueryOptions() + { + KeepPrevious = true + } + ); + } + + private static QueryResult UseProductListRecord(IViewContext context, ProductListRecord record) + { + var factory = context.UseService(); + return context.UseQuery( + key: (nameof(UseProductListRecord), record.Id), + fetcher: async ct => + { + await using var db = factory.CreateDbContext(); + return await db.Products + .Where(e => e.Id == record.Id) + .Select(e => new ProductListRecord(e.Id, e.Name, e.Category != null ? e.Category.Name : null)) + .FirstOrDefaultAsync(ct); + }, + options: new QueryOptions { RevalidateOnMount = false }, + initialValue: record, + tags: [(typeof(Product), record.Id)] + ); + } +} \ No newline at end of file diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index e3cfb1b..234b628 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -23,4 +23,8 @@ + + + + From 476c6576e31f58b9e4cb7e19a72993acd181639a Mon Sep 17 00:00:00 2001 From: "Christopher Clemons (Windows)" Date: Mon, 26 Jan 2026 17:56:29 +0100 Subject: [PATCH 09/17] fix namespace --- .../Apps/Publishing/Products/IProductsPublishingService.cs | 2 +- .../Apps/Publishing/Products/ProductsPublishingApp.cs | 2 +- Storage.Internal/Program.cs | 2 +- Storage.Internal/Services/ProductsPublishingService.cs | 2 +- Storage.Internal/Storage.Internal.csproj | 4 ---- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs b/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs index 087e943..51ebd2f 100644 --- a/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs +++ b/Storage.Internal/Apps/Publishing/Products/IProductsPublishingService.cs @@ -1,6 +1,6 @@ using System; -namespace Storage.Core.Apps.Publishing.products; +namespace Storage.Core.Apps.Publishing.Products; public interface IProductsPublishingService { diff --git a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs index 979f3ed..08d9873 100644 --- a/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs +++ b/Storage.Internal/Apps/Publishing/Products/ProductsPublishingApp.cs @@ -1,5 +1,5 @@ using Storage.Core.Apps.Publishing; -using Storage.Core.Apps.Publishing.products; +using Storage.Core.Apps.Publishing.Products; namespace Storage.Internal.Apps.Publishing; diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 791d3f3..41b41a3 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -1,5 +1,5 @@ using Storage.Core.Apps.Publishing.Categories; -using Storage.Core.Apps.Publishing.products; +using Storage.Core.Apps.Publishing.Products; using Storage.Core.Services; using Storage.Internal; using Storage.Internal.Apps.Publishing; diff --git a/Storage.Internal/Services/ProductsPublishingService.cs b/Storage.Internal/Services/ProductsPublishingService.cs index 1ba5854..e05a3ec 100644 --- a/Storage.Internal/Services/ProductsPublishingService.cs +++ b/Storage.Internal/Services/ProductsPublishingService.cs @@ -1,6 +1,6 @@ using System; using Ivy.Connections; -using Storage.Core.Apps.Publishing.products; +using Storage.Core.Apps.Publishing.Products; namespace Storage.Core.Connections.StorageInternal; diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index 234b628..e3cfb1b 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -23,8 +23,4 @@ - - - - From b944577cef2972c8688cc915e8c09a90b166ec9c Mon Sep 17 00:00:00 2001 From: "Christopher Clemons (Windows)" Date: Mon, 26 Jan 2026 17:57:41 +0100 Subject: [PATCH 10/17] fix: remove default to empty string in Multiline --- .../Products/Views/ProductsApp.ProductDetailsBlade.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs index 10166db..275a3c3 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs @@ -61,7 +61,7 @@ public class ProductDetailsBlade(int productId) : ViewBase Images = productValue.Images.Select(i => i.Src).ToList() } .ToDetails() - .MultiLine(e => e.Description ?? "") + .MultiLine(e => e.Description) .RemoveEmpty() .Builder(e => e.Id, e => e.CopyToClipboard()), footer: Layout.Horizontal().Gap(2).Align(Align.Right) From 799108d5e159c1871e4729507a6cc33ea79fe844 Mon Sep 17 00:00:00 2001 From: "Christopher Clemons (Windows)" Date: Mon, 26 Jan 2026 18:08:10 +0100 Subject: [PATCH 11/17] replace hardcoded currency symbols --- .../Products/Views/ProductsApp.ProductCreateDialog.cs | 4 ++-- .../Publishing/Products/Views/ProductsApp.ProductEditSheet.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs index 60fa5d1..b9fdd65 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductCreateDialog.cs @@ -32,8 +32,8 @@ private record ProductCreateRequest return product .ToForm() - .Builder(e => e.Price, e => e.ToMoneyInput().Currency("USD")) - .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.Price, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) + .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) .Builder(e => e.CategoryId, e => e.ToAsyncSelectInput(UseCategorySearch, UseCategoryLookup, placeholder: "Select Category")) .HandleSubmit(OnSubmit) .ToDialog(isOpen, title: "Create Product", submitTitle: "Create"); diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs index 3c9c4ec..7b17453 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs @@ -23,8 +23,8 @@ public class ProductEditSheet(IState isOpen, int productId) : ViewBase return productQuery.Value .ToForm() .Builder(e => e.Name, e => e.ToTextInput()) - .Builder(e => e.Price, e => e.ToMoneyInput().Currency("USD")) - .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency("USD")) + .Builder(e => e.Price, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) + .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) .Builder(e => e.InventoryCount, e => e.ToNumberInput()) .Builder(e => e.Shelf, e => e.ToTextInput()) .Builder(e => e.Description, e => e.ToTextAreaInput()) From 13945ceea7673aa38bc067c91686692f2ecf0629 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Tue, 27 Jan 2026 22:52:58 +0100 Subject: [PATCH 12/17] add IPublishingRepository --- .../Apps/Publishing/IPublishingRepository.cs | 9 ++++ .../Views/ProductsApp.ProductEditSheet.cs | 45 +++++++++++++++---- Storage.Internal/Program.cs | 4 +- .../Services/PublishingRepository.cs | 28 ++++++++++++ Storage.Internal/Storage.Internal.csproj | 4 ++ 5 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 Storage.Internal/Apps/Publishing/IPublishingRepository.cs create mode 100644 Storage.Internal/Services/PublishingRepository.cs diff --git a/Storage.Internal/Apps/Publishing/IPublishingRepository.cs b/Storage.Internal/Apps/Publishing/IPublishingRepository.cs new file mode 100644 index 0000000..c8186ed --- /dev/null +++ b/Storage.Internal/Apps/Publishing/IPublishingRepository.cs @@ -0,0 +1,9 @@ +using System; +using Storage.Models; + +namespace Storage.Core.Apps.Publishing; + +public interface IPublishingRepository +{ + public Task EditProductAsync(ProductEditDto productEditDto); +} diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs index 7b17453..1d8f34c 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs @@ -1,3 +1,7 @@ +using Storage.Core.Apps.Publishing; +using Storage.Models; +using Storage.Models.ViewModels; + namespace Storage.Core.Apps.Views; public class ProductEditSheet(IState isOpen, int productId) : ViewBase @@ -6,13 +10,27 @@ public class ProductEditSheet(IState isOpen, int productId) : ViewBase { var factory = UseService(); var queryService = UseService(); + var publishingRepository = UseService(); var productQuery = UseQuery( - key: (typeof(Product), productId), + key: (typeof(ProductEditViewModel), productId), fetcher: async ct => { await using var db = factory.CreateDbContext(); - return await db.Products.FirstAsync(e => e.Id == productId, ct); + var queryResult = await db.Products.FirstAsync(e => e.Id == productId, ct); + + return new ProductEditViewModel + { + Id = queryResult.Id, + Name = queryResult.Name, + Price = queryResult.Price, + PurchasePrice = queryResult.PurchasePrice, + InventoryCount = queryResult.InventoryCount, + OrderDate = queryResult.OrderDate, + Shelf = queryResult.Shelf, + CategoryId = queryResult.CategoryId, + Description = queryResult.Description ?? "" + }; }, tags: [(typeof(Product), productId)] ); @@ -29,17 +47,28 @@ public class ProductEditSheet(IState isOpen, int productId) : ViewBase .Builder(e => e.Shelf, e => e.ToTextInput()) .Builder(e => e.Description, e => e.ToTextAreaInput()) .Builder(e => e.CategoryId, e => e.ToAsyncSelectInput(UseCategorySearch, UseCategoryLookup, placeholder: "Select Category")) - .Remove(e => e.Id, e => e.CreatedAt, e => e.UpdatedAt) + .Remove(e => e.Id) .HandleSubmit(OnSubmit) .ToSheet(isOpen, "Edit Product"); - async Task OnSubmit(Product? request) + async Task OnSubmit(ProductEditViewModel? request) { if (request == null) return; - await using var db = factory.CreateDbContext(); - request.UpdatedAt = DateTime.UtcNow; - db.Products.Update(request); - await db.SaveChangesAsync(); + + ProductEditDto productEditDto = new + ( + Id: request.Id, + Name: request.Name, + Price: request.Price, + PurchasePrice: request.PurchasePrice, + OrderDate: request.OrderDate, + CategoryId: request.CategoryId, + Shelf: request.Shelf, + InventoryCount: request.InventoryCount, + Description: request.Description + ); + + await publishingRepository.EditProductAsync(productEditDto); queryService.RevalidateByTag((typeof(Product), productId)); } } diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 41b41a3..7c12137 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -1,4 +1,5 @@ -using Storage.Core.Apps.Publishing.Categories; +using Storage.Core.Apps.Publishing; +using Storage.Core.Apps.Publishing.Categories; using Storage.Core.Apps.Publishing.Products; using Storage.Core.Services; using Storage.Internal; @@ -21,6 +22,7 @@ var storageInternalConnection = new StorageInternalConnection(); storageInternalConnection.RegisterServices(server.Services); +server.Services.AddScoped(); server.Services.AddScoped(); server.Services.AddScoped(); diff --git a/Storage.Internal/Services/PublishingRepository.cs b/Storage.Internal/Services/PublishingRepository.cs new file mode 100644 index 0000000..15307e8 --- /dev/null +++ b/Storage.Internal/Services/PublishingRepository.cs @@ -0,0 +1,28 @@ +using System; +using Storage.Core.Apps.Publishing; +using Storage.Models; + +namespace Storage.Core.Services; + +public class PublishingRepository(StorageInternalContextFactory contextFactory) : IPublishingRepository +{ + public async Task EditProductAsync(ProductEditDto productEditDto) + { + await using var dbContext = contextFactory.CreateDbContext(); + + Product product = await dbContext.Products.FindAsync(productEditDto.Id) + ?? throw new KeyNotFoundException(message: string.Format("Could not find Product with Id {0}", productEditDto.Id)); + + product.Name = productEditDto.Name ?? product.Name; + product.Price = productEditDto.Price ?? product.Price; + product.PurchasePrice = productEditDto.PurchasePrice ?? product.PurchasePrice; + product.OrderDate = productEditDto.OrderDate ?? product.OrderDate; + product.CategoryId = productEditDto.CategoryId ?? product.CategoryId; + product.Shelf = productEditDto.Shelf ?? product.Shelf; + product.InventoryCount = productEditDto.InventoryCount ?? product.InventoryCount; + product.Description = productEditDto.Description ?? product.Description; + + dbContext.Products.Update(product); + await dbContext.SaveChangesAsync(); + } +} diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index e3cfb1b..2ea0032 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -23,4 +23,8 @@ + + + + From 45e249ae4946204dff0a80518d89600f708504d1 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Tue, 27 Jan 2026 23:18:09 +0100 Subject: [PATCH 13/17] set system CultureInfo --- Storage.Internal/Program.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 7c12137..137a4a6 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -14,6 +14,10 @@ .DefaultApp() .UseTabs(preventDuplicates: true); +CultureInfo systemCulture = new("sv-SE"); +CultureInfo.DefaultThreadCurrentCulture = systemCulture; +CultureInfo.DefaultThreadCurrentUICulture = systemCulture; + var server = new Server(); server.UseHotReload(); server.AddAppsFromAssembly(); From e19680c57b263a03374015281295583430d6788d Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Tue, 27 Jan 2026 23:21:54 +0100 Subject: [PATCH 14/17] address warnings, make Description optional --- Storage.Core/Entities/Category.cs | 2 +- Storage.Core/Entities/ProductImage.cs | 4 ++-- Storage.Infrastructure/SeedData.cs | 4 +--- .../Publishing/Categories/CategoriesPublishingListItem.cs | 2 +- Storage.Mvc/Controllers/ProductsController.cs | 2 +- Storage.Mvc/Extensions/WebApplicationExtensions.cs | 2 +- Storage.Mvc/Models/ViewModels/ProductEditViewModel.cs | 5 ++--- Storage.Mvc/Services/ProductService.cs | 2 +- Storage.Mvc/Views/Products/Edit.cshtml | 6 +++--- 9 files changed, 13 insertions(+), 16 deletions(-) diff --git a/Storage.Core/Entities/Category.cs b/Storage.Core/Entities/Category.cs index bfd2489..7638eaa 100644 --- a/Storage.Core/Entities/Category.cs +++ b/Storage.Core/Entities/Category.cs @@ -10,6 +10,6 @@ public class Category : BaseEntity public string Name { get; set; } = string.Empty; public string? Description { get; set; } - public ICollection Products { get; set; } + public ICollection Products { get; set; } = []; } } diff --git a/Storage.Core/Entities/ProductImage.cs b/Storage.Core/Entities/ProductImage.cs index b7d63af..e1c088e 100644 --- a/Storage.Core/Entities/ProductImage.cs +++ b/Storage.Core/Entities/ProductImage.cs @@ -9,7 +9,7 @@ public class ProductImage public int ImageId { get; set; } public int ProductId { get; set; } - public Image Image { get; set; } - public Product Product { get; set; } + public required Image Image { get; set; } + public required Product Product { get; set; } } } diff --git a/Storage.Infrastructure/SeedData.cs b/Storage.Infrastructure/SeedData.cs index 7d25ab0..5bb6ee9 100644 --- a/Storage.Infrastructure/SeedData.cs +++ b/Storage.Infrastructure/SeedData.cs @@ -10,14 +10,12 @@ namespace Storage.Infrastructure { public class SeedData { - private static Faker faker; + private static readonly Faker faker = new(); public static async Task InitAsync(StorageContext context) { if (await context.Products.AnyAsync()) return; - faker = new(); - IEnumerable categories = GenerateCategories(5); await context.AddRangeAsync(categories); diff --git a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs index db9ca8e..4acb3e1 100644 --- a/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs +++ b/Storage.Internal/Apps/Publishing/Categories/CategoriesPublishingListItem.cs @@ -13,6 +13,6 @@ public class CategoriesPublishingListItem public string Name { get; set; } = default!; - public string Description { get; set; } = string.Empty; + public string? Description { get; set; } } } diff --git a/Storage.Mvc/Controllers/ProductsController.cs b/Storage.Mvc/Controllers/ProductsController.cs index 57a6754..27378f0 100644 --- a/Storage.Mvc/Controllers/ProductsController.cs +++ b/Storage.Mvc/Controllers/ProductsController.cs @@ -313,7 +313,7 @@ public async Task Edit([Bind("Id,Name,Price,OrderDate,Category,Ca OrderDate: productEditViewModel.OrderDate, CategoryId: productEditViewModel.CategoryId, Shelf: productEditViewModel.Shelf, - InventoryCount: productEditViewModel.Count, + InventoryCount: productEditViewModel.InventoryCount, Description: productEditViewModel.Description // image ); diff --git a/Storage.Mvc/Extensions/WebApplicationExtensions.cs b/Storage.Mvc/Extensions/WebApplicationExtensions.cs index 08a5792..66cc223 100644 --- a/Storage.Mvc/Extensions/WebApplicationExtensions.cs +++ b/Storage.Mvc/Extensions/WebApplicationExtensions.cs @@ -20,7 +20,7 @@ public static async Task SeedDataAsync(this IApplicationBuilder app) { await SeedData.InitAsync(context); } - catch (Exception ex) { + catch { throw; } } diff --git a/Storage.Mvc/Models/ViewModels/ProductEditViewModel.cs b/Storage.Mvc/Models/ViewModels/ProductEditViewModel.cs index 8d7d098..e57540a 100644 --- a/Storage.Mvc/Models/ViewModels/ProductEditViewModel.cs +++ b/Storage.Mvc/Models/ViewModels/ProductEditViewModel.cs @@ -33,12 +33,11 @@ public class ProductEditViewModel public string Shelf { get; set; } = default!; - [Required] [Range(0, int.MaxValue, ErrorMessage = "{0} must be a number between {2} and {1}")] - public int Count { get; set; } + public int InventoryCount { get; set; } [StringLength(200)] - public string? Description { get; set; } = string.Empty; + public string? Description { get; set; } public ImageInputViewModel? Image { get; set; } diff --git a/Storage.Mvc/Services/ProductService.cs b/Storage.Mvc/Services/ProductService.cs index afeefed..d8243cc 100644 --- a/Storage.Mvc/Services/ProductService.cs +++ b/Storage.Mvc/Services/ProductService.cs @@ -62,7 +62,7 @@ public ProductEditViewModel MapProductEditViewModel(Product product, IEnumerable // Category = product.Category, CategoryId = product.CategoryId, Shelf = product.Shelf, - Count = product.InventoryCount, + InventoryCount = product.InventoryCount, Description = product.Description, //Image = MapImageInputViewModel(product.Image), CategorySelectItems = categorySelectItems, diff --git a/Storage.Mvc/Views/Products/Edit.cshtml b/Storage.Mvc/Views/Products/Edit.cshtml index 4019293..12f135b 100644 --- a/Storage.Mvc/Views/Products/Edit.cshtml +++ b/Storage.Mvc/Views/Products/Edit.cshtml @@ -50,9 +50,9 @@
- - - + + +
From 2e00372f26fae29203cff0960b9ff890f94a3cde Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Thu, 29 Jan 2026 11:13:30 +0100 Subject: [PATCH 15/17] make InventoryCount read-only in product publishing edit --- .../Publishing/Products/Views/ProductsApp.ProductEditSheet.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs index 1d8f34c..fc706e5 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductEditSheet.cs @@ -43,7 +43,7 @@ public class ProductEditSheet(IState isOpen, int productId) : ViewBase .Builder(e => e.Name, e => e.ToTextInput()) .Builder(e => e.Price, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) .Builder(e => e.PurchasePrice, e => e.ToMoneyInput().Currency(RegionInfo.CurrentRegion.ISOCurrencySymbol)) - .Builder(e => e.InventoryCount, e => e.ToNumberInput()) + .Builder(e => e.InventoryCount, e => e.ToReadOnlyInput()) .Builder(e => e.Shelf, e => e.ToTextInput()) .Builder(e => e.Description, e => e.ToTextAreaInput()) .Builder(e => e.CategoryId, e => e.ToAsyncSelectInput(UseCategorySearch, UseCategoryLookup, placeholder: "Select Category")) From 5e8455e7d22820e8261f88cb23de93623f0fb029 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Thu, 29 Jan 2026 11:15:08 +0100 Subject: [PATCH 16/17] add ProductListItemViewModel in product publishing --- .../ViewModels/ProductListItemViewModel.cs | 17 +++++++ .../Views/ProductsApp.ProductListBlade.cs | 50 +++++++++++++++---- Storage.Internal/Storage.Internal.csproj | 5 ++ 3 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs diff --git a/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs new file mode 100644 index 0000000..e266934 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs @@ -0,0 +1,17 @@ +using System; + +namespace Storage.Core.Apps.Publishing.Products.ViewModels; + +public class ProductListItemViewModel +{ + public int Id { get; set; } + + public required string Name { get; set; } + + public required string CategoryName { get; set; } + + public int InventoryCount { get; set; } + + public decimal Price { get; set; } +} + diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs index 88957a0..95f3cd1 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs @@ -1,16 +1,18 @@ +using Storage.Core.Apps.Publishing.Products.Components; +using Storage.Core.Apps.Publishing.Products.ViewModels; + namespace Storage.Core.Apps.Views; public class ProductListBlade : ViewBase { - private record ProductListRecord(int Id, string Name, string? CategoryName); + private record ProductListRecord( + int Id, string Name, string? CategoryName, int InventoryCount, decimal Price); public override object? Build() { var blades = UseContext(); var refreshToken = UseRefreshToken(); - var filter = UseState(""); - var productsQuery = UseProductListRecords(Context, filter.Value); UseEffect(() => @@ -25,19 +27,32 @@ private record ProductListRecord(int Id, string Name, string? CategoryName); var onItemClicked = new Action>(e => { - var product = (ProductListRecord)e.Sender.Tag!; + var product = (ProductListItemViewModel)e.Sender.Tag!; blades.Push(this, new ProductDetailsBlade(product.Id), product.Name); }); - object CreateItem(ProductListRecord listRecord) => new FuncView(context => + object CreateItem(ProductListItemViewModel listRecord) => new FuncView(context => { var itemQuery = UseProductListRecord(context, listRecord); if (itemQuery.Loading || itemQuery.Value == null) { return new ListItem(); } - var record = itemQuery.Value; - return new ListItem(title: record.Name, subtitle: record.CategoryName, onClick: onItemClicked, tag: record); + var product = itemQuery.Value; + + return new ListItem( + title: product.Name, + subtitle: string.Format("{0:C2} - {1}", product.Price, product.InventoryCount > 0 ? product.InventoryCount + " in stock" : "out of stock"), + tag: product, + onClick: onItemClicked, + // Setting `items` doesn't seem to have ant effect? + items: + [ + Layout.Horizontal().Gap(2) + | Text.Block(product.Price.ToString("{0:C1}")) + | new StockStatusBadge(product.InventoryCount) + ] + ); }); var createBtn = Icons.Plus.ToButton(_ => @@ -56,7 +71,7 @@ private record ProductListRecord(int Id, string Name, string? CategoryName); | (productsQuery.Value == null ? Text.Muted("Loading...") : new List(items)); } - private static QueryResult UseProductListRecords(IViewContext context, string filter) + private static QueryResult UseProductListRecords(IViewContext context, string filter) { var factory = context.UseService(); return context.UseQuery( @@ -76,7 +91,14 @@ private static QueryResult UseProductListRecords(IViewConte return await linq .OrderByDescending(e => e.CreatedAt) .Take(50) - .Select(e => new ProductListRecord(e.Id, e.Name, e.Category != null ? e.Category.Name : null)) + .Select(e => new ProductListItemViewModel + { + Id = e.Id, + Name = e.Name, + CategoryName = e.Category.Name, + InventoryCount = e.InventoryCount, + Price = e.Price + }) .ToArrayAsync(ct); }, tags: [typeof(Product[])], @@ -87,7 +109,7 @@ private static QueryResult UseProductListRecords(IViewConte ); } - private static QueryResult UseProductListRecord(IViewContext context, ProductListRecord record) + private static QueryResult UseProductListRecord(IViewContext context, ProductListItemViewModel record) { var factory = context.UseService(); return context.UseQuery( @@ -97,7 +119,13 @@ private static QueryResult UseProductListRecords(IViewConte await using var db = factory.CreateDbContext(); return await db.Products .Where(e => e.Id == record.Id) - .Select(e => new ProductListRecord(e.Id, e.Name, e.Category != null ? e.Category.Name : null)) + .Select(e => new ProductListItemViewModel { + Id = e.Id, + Name = e.Name, + CategoryName = e.Category.Name, + InventoryCount = e.InventoryCount, + Price = e.Price + }) .FirstOrDefaultAsync(ct); }, options: new QueryOptions { RevalidateOnMount = false }, diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index 2ea0032..f9850c0 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -27,4 +27,9 @@ + + + + + From 9ec3bdfee9fc2e1615c4aa0fd03d701dd02d2382 Mon Sep 17 00:00:00 2001 From: Christopher Clemons Date: Thu, 29 Jan 2026 12:26:41 +0100 Subject: [PATCH 17/17] add StockInfo, StockStatusBadge and StockStatusContentBuilder --- .../Products/Components/StockStatusBadge.cs | 47 +++++++++++++++++++ .../ViewModels/ProductDetailsViewModel.cs | 29 ++++++++++++ .../ViewModels/ProductListItemViewModel.cs | 2 + .../Products/ViewModels/StockInfo.cs | 29 ++++++++++++ .../Views/ProductsApp.ProductDetailsBlade.cs | 11 +++-- .../Views/ProductsApp.ProductListBlade.cs | 2 +- Storage.Internal/Program.cs | 9 +++- Storage.Internal/Storage.Internal.csproj | 1 - 8 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 Storage.Internal/Apps/Publishing/Products/Components/StockStatusBadge.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/ViewModels/ProductDetailsViewModel.cs create mode 100644 Storage.Internal/Apps/Publishing/Products/ViewModels/StockInfo.cs diff --git a/Storage.Internal/Apps/Publishing/Products/Components/StockStatusBadge.cs b/Storage.Internal/Apps/Publishing/Products/Components/StockStatusBadge.cs new file mode 100644 index 0000000..4226d4c --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/Components/StockStatusBadge.cs @@ -0,0 +1,47 @@ +using System; +using System.Diagnostics; +using Storage.Core.Apps.Publishing.Products.ViewModels; + +namespace Storage.Core.Apps.Publishing.Products.Components; + +public class StockStatusBadge(StockInfo stockInfo) : ViewBase +{ + public override object? Build() + { + return stockInfo.Status switch + { + StockStatus.OutOfStock => new Badge("out of stock").Variant(BadgeVariant.Destructive), + StockStatus.Low => new Badge(stockInfo.Count.ToString("0 in stock")).Variant(BadgeVariant.Warning), + _ => new Badge(stockInfo.Count.ToString("0 in stock")).Variant(BadgeVariant.Primary), + }; + } +} + +public class StockStatusContentBuilder : IContentBuilder +{ + public bool CanHandle(object? content) + { + return content is StockInfo; + } + + public object? Format(object? content) + { + if (content is StockInfo info) + { + return new StockStatusBadge(info); + } + // Transform your custom type into a visual representation + return content; + } + + private StockStatus GetStockStatus(int inventoryCount) + { + return inventoryCount switch + { + 0 => StockStatus.OutOfStock, + <=10 => StockStatus.Low, + _ => StockStatus.Default + }; + } +} + diff --git a/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductDetailsViewModel.cs b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductDetailsViewModel.cs new file mode 100644 index 0000000..fd8ca17 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductDetailsViewModel.cs @@ -0,0 +1,29 @@ +using Storage.Core.Apps.Publishing.Products.ViewModels; +using ProductImage = Storage.Core.Connections.StorageInternal.Image; + +namespace Storage.Core.Apps.Publishing.Products.Views; + +public class ProductDetailsViewModel +{ + public int Id { get; init; } + + public required string Name { get; init; } + + [DataType(DataType.Currency)] + public required decimal Price { get; init; } + + [DataType(DataType.Currency)] + public required decimal PurchasePrice { get; init; } + + public required int InventoryCount { get; init; } + + public StockInfo StockInfo { get => new(InventoryCount); } + + public required string Shelf { get; init; } + + public required string CategoryName { get; init; } + + public required string Description { get; init; } + + public IEnumerable Images { get; set; } = []; +} diff --git a/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs index e266934..0eaf0fc 100644 --- a/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs +++ b/Storage.Internal/Apps/Publishing/Products/ViewModels/ProductListItemViewModel.cs @@ -12,6 +12,8 @@ public class ProductListItemViewModel public int InventoryCount { get; set; } + public StockInfo StockInfo { get => new(InventoryCount); } + public decimal Price { get; set; } } diff --git a/Storage.Internal/Apps/Publishing/Products/ViewModels/StockInfo.cs b/Storage.Internal/Apps/Publishing/Products/ViewModels/StockInfo.cs new file mode 100644 index 0000000..0676f88 --- /dev/null +++ b/Storage.Internal/Apps/Publishing/Products/ViewModels/StockInfo.cs @@ -0,0 +1,29 @@ +namespace Storage.Core.Apps.Publishing.Products.ViewModels; + +public class StockInfo +{ + public StockStatus Status { get; init; } + public int Count { get; init; } + + public StockInfo(int inventoryCount) + { + Status = GetStockStatus(inventoryCount); + Count = inventoryCount; + } + + private static StockStatus GetStockStatus(int inventoryCount) + { + return inventoryCount switch + { + 0 => StockStatus.OutOfStock, + <=10 => StockStatus.Low, + _ => StockStatus.Default + }; + } +} +public enum StockStatus +{ + Default, + Low, + OutOfStock +} diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs index 275a3c3..9eb30f2 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductDetailsBlade.cs @@ -1,3 +1,6 @@ +using Storage.Core.Apps.Publishing.Products.Components; +using Storage.Core.Apps.Publishing.Products.Views; + namespace Storage.Core.Apps.Views; public class ProductDetailsBlade(int productId) : ViewBase @@ -48,7 +51,7 @@ public class ProductDetailsBlade(int productId) : ViewBase .ToTrigger((isOpen) => new ProductEditSheet(isOpen, productId)); var detailsCard = new Card( - content: new + content: new ProductDetailsViewModel { Id = productValue.Id, Name = productValue.Name, @@ -56,9 +59,9 @@ public class ProductDetailsBlade(int productId) : ViewBase PurchasePrice = productValue.PurchasePrice, InventoryCount = productValue.InventoryCount, Shelf = productValue.Shelf, - Category = productValue.Category.Name, - Description = productValue.Description, - Images = productValue.Images.Select(i => i.Src).ToList() + CategoryName = productValue.Category.Name, + Description = productValue.Description ?? string.Empty, + Images = productValue.Images } .ToDetails() .MultiLine(e => e.Description) diff --git a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs index 95f3cd1..2e3af56 100644 --- a/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs +++ b/Storage.Internal/Apps/Publishing/Products/Views/ProductsApp.ProductListBlade.cs @@ -50,7 +50,7 @@ private record ProductListRecord( [ Layout.Horizontal().Gap(2) | Text.Block(product.Price.ToString("{0:C1}")) - | new StockStatusBadge(product.InventoryCount) + | new StockStatusBadge(product.StockInfo) ] ); }); diff --git a/Storage.Internal/Program.cs b/Storage.Internal/Program.cs index 137a4a6..2c4a81d 100644 --- a/Storage.Internal/Program.cs +++ b/Storage.Internal/Program.cs @@ -1,6 +1,7 @@ using Storage.Core.Apps.Publishing; using Storage.Core.Apps.Publishing.Categories; using Storage.Core.Apps.Publishing.Products; +using Storage.Core.Apps.Publishing.Products.Components; using Storage.Core.Services; using Storage.Internal; using Storage.Internal.Apps.Publishing; @@ -18,7 +19,13 @@ CultureInfo.DefaultThreadCurrentCulture = systemCulture; CultureInfo.DefaultThreadCurrentUICulture = systemCulture; -var server = new Server(); +var builder = new ContentBuilder(); +builder + .Use(new StockStatusContentBuilder()) + .Use(new DefaultContentBuilder()); + +var server = new Server() + .UseContentBuilder(builder); server.UseHotReload(); server.AddAppsFromAssembly(); server.AddConnectionsFromAssembly(); diff --git a/Storage.Internal/Storage.Internal.csproj b/Storage.Internal/Storage.Internal.csproj index f9850c0..0e80ec7 100644 --- a/Storage.Internal/Storage.Internal.csproj +++ b/Storage.Internal/Storage.Internal.csproj @@ -29,7 +29,6 @@ -