diff --git a/.github/prompts/wasm-sample.prompt.md b/.github/prompts/wasm-sample.prompt.md new file mode 100644 index 0000000..94ec83f --- /dev/null +++ b/.github/prompts/wasm-sample.prompt.md @@ -0,0 +1,9 @@ +This repository contains an open source project for accessing databases through a client-server relationship with LINQ. You can find many samples in the samples directory, but the main one is todoapp, which contains samples for Avalonia, MAUI, Uno, WinUI3, and WPF. There is also a todoapp-mvc that encapsulates a service and client using JavaScript. + +Project Layout: + +- `docs` contains full documentation for the library. `docs/in-depth/client/online.md` is a good resource for online client operations. +- `samples` contains a set of samples. +- `src` and `test` contain the source code and tests for the libraries we distribute. + +Your job is to create a working sample for Blazor WASM. It should implement a TodoList type application (common with the other samples), encapsulated in a server (see todoapp-mvc for an example server implementation). The solution should be placed in `samples/todoapp-blazor-wasm` and should be runnable with F5-Run. Use an in-memory EF Core store for storing the data (so we aren't reliant on an external database service). diff --git a/docs/in-depth/client/advanced/blazor-wasm.md b/docs/in-depth/client/advanced/blazor-wasm.md new file mode 100644 index 0000000..77cd335 --- /dev/null +++ b/docs/in-depth/client/advanced/blazor-wasm.md @@ -0,0 +1,34 @@ +# Blazor WASM Support + +You can use Blazor WASM with the Datasync Community Toolkit; however, there are some signficant problems: + +1. In the general case, you cannot use SQLite for offline storage. +2. You will have to suppress the `WASM0001` warning for sqlite. + +## Do not use offline mode + +It is possible to use offline mode by storing the SQLite database in local storage within the browser. When you start up your application, you restore the SQLite in-memory database into memory; on a regular basis, you write the SQLite database to local storage. However, this system severely affects performance within the browser and large synchronizations will cause the local storage to overflow. As a result of these problems, it is not recommended, nor is it supported. + +## Suppress the `WASM0001` warning + +When compiling the WASM client, you will see warning `WASM0001`. This is a harmless warning that indicates SQLite is not available. However, you may be running using "Warnings as Errors". The most appropriate solution is to suppress the `WASM0001` warning in your client `.csproj` file as follows: + +```xml + + $(NoWarn);WASM0001 + +``` + +Alternatively, you may also use the following to keep `WASM0001` as a warning even when using "Warnings as Errors": + +```xml + + $(WarningsNotAsErrors);WASM0001 + +``` + +This will suppress the harmless SQLite warning that appears when building Blazor WASM applications that reference libraries containing SQLite dependencies (even when not using SQLite directly). + +## Sample + +We have a Blazor WASM sample in our sample set: [samples/todoapp-blazor-wasm](https://github.com/CommunityToolkit/Datasync/tree/main/samples/todoapp-blazor-wasm). This sample comprises a server (which uses an in-memory EF Core database) combined with a Blazor Web Assembly client. diff --git a/docs/in-depth/client/index.md b/docs/in-depth/client/index.md index 8c7d207..5a8a27f 100644 --- a/docs/in-depth/client/index.md +++ b/docs/in-depth/client/index.md @@ -1,9 +1,9 @@ # Creating offline clients -This guide shows you how to perform common scenarios using the Datasync Community Toolkit. Use the client library in any .NET 8 application, including AvaloniaUI, MAUI, Uno Platform, WinUI, and WPF applications. +This guide shows you how to perform common scenarios using the Datasync Community Toolkit. Use the client library in any .NET 9 application, including AvaloniaUI, MAUI, Uno Platform, WinUI, and WPF applications. !!! note **Blazor WASM and Blazor Hybrid** - The offline capabilities are known to have issues with Blazor WASM and Blazor Hybrid (since EF Core and SQLite do not work in those environments when running in the browser). Use online-only operations in these environments. + The offline capabilities are known to have issues with Blazor WASM and Blazor Hybrid (since EF Core and SQLite do not work in those environments when running in the browser). Use online-only operations in these environments. For more information, see [our guide on Blazor WASM](./advanced/blazor-wasm.md) This guide primary deals with offline operations. For online operations, see the [Online operations guide](./online.md). diff --git a/docs/in-depth/client/online.md b/docs/in-depth/client/online.md index cd661ca..0626b88 100644 --- a/docs/in-depth/client/online.md +++ b/docs/in-depth/client/online.md @@ -2,6 +2,8 @@ Not all data needs to be synchronized. You may want to do an online search of records for a search capability, for example. To support this, The Datasync Community Toolkit supports an online client in addition to offline usage. +If you are using the Datasync Community Toolkit with Blazor WASM, see [our guide on Blazor WASM usage](./advanced/blazor-wasm.md). + ## Creating a Http Client Factory To create an online client, you must create an `IHttpClientFactory` that creates the appropriate `HttpClient` objects that are used to communicate with the remote service. This can handle authentication, logging, and anything else that is required by the remote service. At a minimum, a `BaseAddress` must be established. To facilitate this, the Datasync Community Toolkit provides a default `HttpClientFactory` that can be used: diff --git a/docs/index.md b/docs/index.md index 1b2ce74..49995b8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ Currently, the library supports: Client platforms that have been tested include: * [Avalonia][avalonia] +* [Blazor WASM][blazor-wasm] * [.NET MAUI][maui] * [Uno Platform][uno] * [Windows Presentation Framework (WPF)][wpf] @@ -43,6 +44,7 @@ Find out more about developing datasync applications: [org-github]: https://learn.microsoft.com/en-us/dotnet/communitytoolkit/ [dotnetfdn]: https://dotnetfoundation.org/ [avalonia]: https://www.avaloniaui.net/ +[blazor-wasm]: https://learn.microsoft.com/en-us/aspnet/core/blazor/webassembly-build-tools-and-aot [maui]: https://dotnet.microsoft.com/apps/maui [uno]: https://platform.uno/ [wpf]: https://learn.microsoft.com/dotnet/desktop/wpf/overview/ diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/App.razor b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/App.razor new file mode 100644 index 0000000..36912bb --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Pages/TodoList.razor b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Pages/TodoList.razor new file mode 100644 index 0000000..b5d46c5 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Pages/TodoList.razor @@ -0,0 +1,259 @@ +@page "/" +@page "/{filter}" +@inject ITodoService TodoService +@inject IJSRuntime JSRuntime + +TodoMVC - Blazor WASM + +
+
+

todos

+ +
+ + @if (todoItems.Any()) + { +
+ + + + +
+ + + } +
+ + + +@code { + [Parameter] public string? Filter { get; set; } + + private List todoItems = new(); + private string newTodoTitle = string.Empty; + private string? editingTodoId; + private string editingTitle = string.Empty; + private bool allCompleted; + private ElementReference editInput; + + private string CurrentFilter => Filter ?? "all"; + + private IEnumerable FilteredTodos => CurrentFilter switch + { + "active" => todoItems.Where(t => !t.Completed), + "completed" => todoItems.Where(t => t.Completed), + _ => todoItems + }; + + private int activeTodoCount => todoItems.Count(t => !t.Completed); + private int completedTodoCount => todoItems.Count(t => t.Completed); + + protected override async Task OnInitializedAsync() + { + await LoadTodos(); + } + + private async Task LoadTodos() + { + try + { + var items = await TodoService.GetTodoItemsAsync(); + todoItems = items.ToList(); + UpdateAllCompletedState(); + } + catch (Exception ex) + { + Console.WriteLine($"Error loading todos: {ex.Message}"); + } + } + + private async Task HandleNewTodoKeyPress(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTodoTitle)) + { + await CreateTodo(); + } + } + + private async Task CreateTodo() + { + try + { + var newItem = await TodoService.CreateTodoItemAsync(newTodoTitle.Trim()); + todoItems.Add(newItem); + newTodoTitle = string.Empty; + UpdateAllCompletedState(); + } + catch (Exception ex) + { + Console.WriteLine($"Error creating todo: {ex.Message}"); + } + } + + private async Task UpdateTodo(TodoItemDto item) + { + try + { + await TodoService.UpdateTodoItemAsync(item); + UpdateAllCompletedState(); + } + catch (Exception ex) + { + Console.WriteLine($"Error updating todo: {ex.Message}"); + } + } + + private async Task ToggleTodo(TodoItemDto item) + { + item.Completed = !item.Completed; + await UpdateTodo(item); + } + + private async Task DeleteTodo(string id) + { + try + { + await TodoService.DeleteTodoItemAsync(id); + todoItems.RemoveAll(t => t.Id == id); + UpdateAllCompletedState(); + } + catch (Exception ex) + { + Console.WriteLine($"Error deleting todo: {ex.Message}"); + } + } + + private async Task ToggleAllTodos() + { + try + { + foreach (var item in todoItems) + { + item.Completed = allCompleted; + await TodoService.UpdateTodoItemAsync(item); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error toggling all todos: {ex.Message}"); + } + } + + private async Task ClearCompleted() + { + try + { + var completedItems = todoItems.Where(t => t.Completed).ToList(); + foreach (var item in completedItems) + { + await TodoService.DeleteTodoItemAsync(item.Id); + } + todoItems.RemoveAll(t => t.Completed); + UpdateAllCompletedState(); + } + catch (Exception ex) + { + Console.WriteLine($"Error clearing completed todos: {ex.Message}"); + } + } + + private async Task StartEditing(string id) + { + editingTodoId = id; + var item = todoItems.First(t => t.Id == id); + editingTitle = item.Title; + + await Task.Delay(1); // Allow DOM to update + await editInput.FocusAsync(); + } + + private async Task HandleEditKeyPress(KeyboardEventArgs e, TodoItemDto item) + { + if (e.Key == "Enter") + { + await SaveEdit(item); + } + else if (e.Key == "Escape") + { + CancelEdit(); + } + } + + private async Task SaveEdit(TodoItemDto item) + { + if (!string.IsNullOrWhiteSpace(editingTitle)) + { + item.Title = editingTitle.Trim(); + await UpdateTodo(item); + } + else + { + await DeleteTodo(item.Id); + } + + editingTodoId = null; + editingTitle = string.Empty; + } + + private void CancelEdit() + { + editingTodoId = null; + editingTitle = string.Empty; + } + + private void UpdateAllCompletedState() + { + allCompleted = todoItems.Any() && todoItems.All(t => t.Completed); + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Program.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Program.cs new file mode 100644 index 0000000..7bf2eb5 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Program.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Client; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using TodoApp.BlazorWasm.Client; +using TodoApp.BlazorWasm.Client.Services; +using TodoApp.BlazorWasm.Shared.Models; + +WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// Configure HTTP client for Datasync +builder.Services.AddHttpClient("DatasyncClient", client => +{ + client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); +}); + +// Register Datasync services +builder.Services.AddScoped>(sp => +{ + IHttpClientFactory httpClientFactory = sp.GetRequiredService(); + HttpClient httpClient = httpClientFactory.CreateClient("DatasyncClient"); + Uri tableEndpoint = new("/tables/todoitems", UriKind.Relative); + return new DatasyncServiceClient(tableEndpoint, httpClient); +}); + +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Properties/launchSettings.json b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Properties/launchSettings.json new file mode 100644 index 0000000..3bfc599 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TodoApp.BlazorWasm.Client": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53033;http://localhost:53034" + } + } +} \ No newline at end of file diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/ITodoService.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/ITodoService.cs new file mode 100644 index 0000000..c382a39 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/ITodoService.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using TodoApp.BlazorWasm.Shared.Models; + +namespace TodoApp.BlazorWasm.Client.Services; + +/// +/// Defines the contract for todo item management services in the Blazor WebAssembly application. +/// This interface provides an abstraction layer for CRUD operations on todo items, enabling +/// dependency injection and testability while supporting datasync capabilities. +/// +/// +/// +/// The interface establishes a service layer contract that abstracts +/// todo item operations from the underlying datasync implementation. This design enables: +/// +/// Dependency injection and inversion of control in Blazor components +/// Unit testing with mock implementations +/// Flexibility to swap datasync implementations without changing consumer code +/// Clear separation of concerns between UI components and data access logic +/// +/// +/// +/// Implementations of this interface should provide: +/// +/// Asynchronous operations suitable for responsive user interfaces +/// Proper error handling with meaningful exception messages +/// Support for offline-first scenarios when using datasync frameworks +/// Optimistic concurrency control for data consistency +/// Soft delete functionality for proper synchronization +/// +/// +/// +/// This interface is typically consumed by Blazor components and pages that need to perform +/// todo item operations, such as the TodoList component which displays and manages the user's todo items. +/// +/// +/// +/// +/// +public interface ITodoService +{ + /// + /// Retrieves all active (non-deleted) todo items. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains an of + /// representing all active todo items available to the current user. + /// + /// + /// + /// This method should return only active todo items, automatically filtering out any + /// items that have been soft-deleted. The implementation should leverage caching + /// and synchronization capabilities when available to provide the most up-to-date + /// data while supporting offline scenarios. + /// + /// + /// The returned collection should be suitable for display in user interface components + /// and may be used for filtering, sorting, and other client-side operations. + /// + /// + /// + /// Thrown when the service encounters an error during the retrieval operation, + /// such as network connectivity issues or server errors. + /// + /// + /// Thrown when the current user is not authorized to access todo items. + /// + Task> GetTodoItemsAsync(); + + /// + /// Creates a new todo item with the specified title. + /// + /// The title or description of the new todo item. + /// + /// A that represents the asynchronous operation. + /// The task result contains the newly created with + /// all server-assigned properties populated, including ID, timestamps, and version information. + /// + /// + /// + /// This method creates a new todo item with the provided title. The implementation + /// should automatically generate appropriate default values for other properties + /// such as the completion status (typically false for new items) and unique identifier. + /// + /// + /// The returned todo item should contain all properties populated by the server, + /// including timestamps for creation and modification, version information for + /// concurrency control, and any other metadata required by the datasync framework. + /// + /// + /// + /// The title for the new todo item. Should not be null, empty, or consist only of whitespace. + /// The title length should comply with validation rules defined in . + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when is empty, consists only of whitespace, + /// or violates length restrictions. + /// + /// + /// Thrown when the service fails to create the todo item due to server errors, + /// network issues, or other operational problems. + /// + /// + /// Thrown when the todo item data fails validation on the server side. + /// + Task CreateTodoItemAsync(string title); + + /// + /// Updates an existing todo item with the provided data. + /// + /// The containing the updated data. + /// + /// A that represents the asynchronous operation. + /// The task result contains the updated with + /// refreshed server-managed properties such as timestamps and version information. + /// + /// + /// + /// This method performs a complete update of the specified todo item. The implementation + /// should use optimistic concurrency control based on version information to detect + /// and handle concurrent modifications by other clients. + /// + /// + /// The method should update all user-modifiable properties of the todo item while + /// preserving system-managed properties. The returned item will contain updated + /// timestamps and version information reflecting the successful modification. + /// + /// + /// If the item has been modified by another client since it was last retrieved, + /// the implementation should detect this conflict and handle it appropriately, + /// either by raising an exception or providing conflict resolution mechanisms. + /// + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when the update operation fails due to server errors, network issues, + /// concurrent modifications, or when the item no longer exists on the server. + /// + /// + /// Thrown when the updated todo item data fails server-side validation. + /// + /// + /// Thrown when the item has been modified by another client and the update + /// cannot be completed due to version conflicts. + /// + Task UpdateTodoItemAsync(TodoItemDto item); + + /// + /// Deletes the todo item with the specified identifier. + /// + /// The unique identifier of the todo item to delete. + /// + /// A that represents the asynchronous delete operation. + /// + /// + /// + /// This method performs a deletion of the specified todo item. The implementation + /// should typically use soft delete functionality, marking the item as deleted + /// rather than physically removing it from storage. This approach ensures proper + /// synchronization of delete operations across multiple clients and supports + /// offline scenarios. + /// + /// + /// After successful deletion, the item should no longer appear in results from + /// but may still exist in the underlying storage + /// for synchronization purposes. + /// + /// + /// The method should handle cases where the item has already been deleted or + /// no longer exists, typically treating such scenarios as successful operations + /// since the desired end state (item not available) has been achieved. + /// + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when is empty or consists only of whitespace. + /// + /// + /// Thrown when the delete operation fails due to server errors, network connectivity + /// issues, or when the operation cannot be completed for other reasons. + /// + /// + /// Thrown when the current user is not authorized to delete the specified todo item. + /// + Task DeleteTodoItemAsync(string id); +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/TodoService.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/TodoService.cs new file mode 100644 index 0000000..e8532c8 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Services/TodoService.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Client; +using TodoApp.BlazorWasm.Shared.Models; + +namespace TodoApp.BlazorWasm.Client.Services; + +/// +/// Provides client-side todo item management functionality for the Blazor WebAssembly application. +/// This service implements and acts as a wrapper around the +/// to provide datasync capabilities with automatic +/// synchronization between client and server. +/// +/// +/// +/// The class encapsulates all CRUD operations for todo items while +/// leveraging the CommunityToolkit.Datasync.Client framework for offline-first capabilities, +/// automatic conflict resolution, and real-time synchronization with the server. +/// +/// +/// This service automatically handles: +/// +/// Optimistic concurrency control using ETags and version tracking +/// Soft delete operations (items are marked as deleted rather than physically removed) +/// Automatic ID generation for new todo items +/// Error handling and meaningful exception messages +/// Filtering of deleted items during retrieval operations +/// +/// +/// +/// The service is designed to work seamlessly with Blazor WebAssembly components and provides +/// asynchronous operations suitable for responsive user interfaces. +/// +/// +/// +/// +/// +public class TodoService(DatasyncServiceClient todoClient) : ITodoService +{ + /// + /// Retrieves all active (non-deleted) todo items from the datasync service. + /// + /// + /// A that represents the asynchronous operation. + /// The task result contains an of + /// representing all active todo items. + /// + /// + /// + /// This method automatically filters out soft-deleted items by applying a LINQ Where clause + /// that excludes items where is true. This ensures + /// that only active todo items are returned to the client application. + /// + /// + /// The method leverages the datasync client's query capabilities and will automatically + /// synchronize with the server if connectivity is available, or return cached data + /// when operating in offline mode. + /// + /// + /// + /// Thrown when the datasync service encounters an error during the query operation. + /// + /// + /// Thrown when network communication with the server fails. + /// + public async Task> GetTodoItemsAsync() + { + List items = await todoClient + .Where(item => !item.Deleted) + .ToListAsync(); + return items; + } + + /// + /// Creates a new todo item with the specified title. + /// + /// The title or description of the new todo item. + /// + /// A that represents the asynchronous operation. + /// The task result contains the newly created with + /// server-assigned properties populated. + /// + /// + /// + /// This method creates a new with an automatically generated + /// GUID identifier, sets the title to the provided value, and initializes the + /// property to false. + /// + /// + /// The method sends the new item to the server via the datasync client's + /// method. Upon successful creation, + /// the server returns the persisted item with updated timestamps and version information. + /// + /// + /// The title for the new todo item. Must not be null or empty. + /// + /// Thrown when is null. + /// + /// + /// Thrown when is empty or whitespace only. + /// + /// + /// Thrown when the server operation fails, containing details about the failure reason. + /// + /// + /// Thrown when the todo item fails server-side validation (e.g., title too long). + /// + public async Task CreateTodoItemAsync(string title) + { + TodoItemDto newItem = new() { Title = title }; + ServiceResponse response = await todoClient.AddAsync(newItem); + if (response.IsSuccessful && response.HasValue) + { + return response.Value!; + } + + throw new InvalidOperationException($"Failed to create todo item: {response.ReasonPhrase}"); + } + + /// + /// Updates an existing todo item with the provided data. + /// + /// The containing the updated data. + /// + /// A that represents the asynchronous operation. + /// The task result contains the updated with + /// server-updated properties such as timestamps and version. + /// + /// + /// + /// This method performs a complete replacement of the todo item on the server using + /// the datasync client's method. + /// The operation includes optimistic concurrency control based on the item's version property. + /// + /// + /// If the item has been modified by another client since it was last retrieved, + /// the server will reject the update with a conflict status, which will be reflected + /// in the returned by the datasync client. + /// + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when the server operation fails, including conflicts due to concurrent modifications, + /// validation failures, or network errors. The exception message includes the server's reason phrase. + /// + /// + /// Thrown when the updated todo item fails server-side validation. + /// + public async Task UpdateTodoItemAsync(TodoItemDto item) + { + ServiceResponse response = await todoClient.ReplaceAsync(item); + if (response.IsSuccessful && response.HasValue) + { + return response.Value!; + } + + throw new InvalidOperationException($"Failed to update todo item: {response.ReasonPhrase}"); + } + + /// + /// Performs a soft delete operation on the todo item with the specified identifier. + /// + /// The unique identifier of the todo item to delete. + /// + /// A that represents the asynchronous delete operation. + /// + /// + /// + /// This method performs a soft delete operation, meaning the todo item is marked as deleted + /// on the server but not physically removed from the database. This approach enables + /// proper synchronization of delete operations across multiple clients and supports + /// offline scenarios where delete operations need to be synchronized later. + /// + /// + /// The method uses to configure the delete operation + /// and leverages the datasync client's + /// method to perform the server-side operation. + /// + /// + /// After successful deletion, the item will no longer appear in results from + /// as it filters out items marked as deleted. + /// + /// + /// + /// Thrown when is null. + /// + /// + /// Thrown when is empty or whitespace only. + /// + /// + /// Thrown when the server operation fails, such as when the item doesn't exist, + /// has already been deleted, or due to network connectivity issues. + /// The exception message includes the server's reason phrase. + /// + public async Task DeleteTodoItemAsync(string id) + { + ServiceResponse response = await todoClient.RemoveAsync(id, new DatasyncServiceOptions()); + if (!response.IsSuccessful) + { + throw new InvalidOperationException($"Failed to delete todo item: {response.ReasonPhrase}"); + } + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Shared/MainLayout.razor b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Shared/MainLayout.razor new file mode 100644 index 0000000..8ff75ae --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/Shared/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +
+
+
+ @Body +
+
+
diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/TodoApp.BlazorWasm.Client.csproj b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/TodoApp.BlazorWasm.Client.csproj new file mode 100644 index 0000000..cd458f5 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/TodoApp.BlazorWasm.Client.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/_Imports.razor b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/_Imports.razor new file mode 100644 index 0000000..4175b20 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using TodoApp.BlazorWasm.Client +@using TodoApp.BlazorWasm.Client.Shared +@using TodoApp.BlazorWasm.Client.Services +@using TodoApp.BlazorWasm.Shared.Models diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/app.css b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/app.css new file mode 100644 index 0000000..d6f11ef --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/app.css @@ -0,0 +1,51 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + +.loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); +} + +.loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; +} + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + +.loading-progress-text:after { + content: "Loading..."; +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/todomvc.css b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/todomvc.css new file mode 100644 index 0000000..5eb4305 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/css/todomvc.css @@ -0,0 +1,388 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #111111; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); +} + +.todoapp h1 { + position: absolute; + top: -140px; + width: 100%; + font-size: 80px; + font-weight: 200; + text-align: center; + color: #b83f45; + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + height: 65px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + width: 1px; + height: 1px; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; + right: 100%; + bottom: 100%; +} + +.toggle-all + label { + display: flex; + align-items: center; + justify-content: center; + width: 45px; + height: 65px; + font-size: 0; + position: absolute; + top: -65px; + left: -0; +} + +.toggle-all + label:before { + content: '❯'; + display: inline-block; + font-size: 22px; + color: #949494; + padding: 10px 27px 10px 27px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all:checked + label:before { + color: #484848; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: calc(100% - 43px); + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://github.com/twbs/bootstrap/issues/20791 + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22m72%2025-42%2042-14-14-10%2010%2024%2024%2052-52z%22%2F%3E%3C%2Fsvg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; + font-weight: 400; + color: #484848; +} + +.todo-list li.completed label { + color: #949494; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #949494; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover, +.todo-list li .destroy:focus { + color: #C18585; +} + +.todo-list li .destroy:after { + content: '×'; + display: block; + height: 100%; + line-height: 1.1; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + padding: 10px 15px; + height: 20px; + text-align: center; + font-size: 15px; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: #DB7676; +} + +.filters li a.selected { + border-color: #CE4646; +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 19px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #4d4d4d; + font-size: 11px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/favicon.png b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/favicon.png new file mode 100644 index 0000000..f1c369c Binary files /dev/null and b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/favicon.png differ diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/index.html b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/index.html new file mode 100644 index 0000000..dab5221 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Client/wwwroot/index.html @@ -0,0 +1,33 @@ + + + + + + + TodoApp.BlazorWasm.Client + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Controllers/TodoItemsController.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Controllers/TodoItemsController.cs new file mode 100644 index 0000000..3e96cf7 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Controllers/TodoItemsController.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.Server.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using TodoApp.BlazorWasm.Server.Database; + +namespace TodoApp.BlazorWasm.Server.Controllers; + +/// +/// Provides RESTful API endpoints and OData query capabilities for managing todo items in the Blazor WebAssembly application. +/// This controller inherits from to provide full CRUDL (Create, Read, Update, Delete, List) +/// operations with datasync capabilities from the CommunityToolkit.Datasync.Server framework. +/// +/// +/// +/// The exposes the following HTTP endpoints for todo item operations: +/// +/// GET /tables/todoitems - Retrieves a list of todo items with optional OData query parameters for filtering, sorting, and paging +/// GET /tables/todoitems/{id} - Retrieves a specific todo item by its unique identifier +/// POST /tables/todoitems - Creates a new todo item +/// PUT /tables/todoitems/{id} - Updates an existing todo item (full replacement) +/// PATCH /tables/todoitems/{id} - Partially updates an existing todo item +/// DELETE /tables/todoitems/{id} - Deletes a todo item (supports both soft and hard delete based on configuration) +/// +/// +/// +/// The controller supports OData query operations including $filter, $orderby, $skip, $top, +/// and $select for advanced querying capabilities. It also provides automatic ETag support for optimistic +/// concurrency control and delta synchronization features for offline-capable applications. +/// +/// +/// All operations are performed through the which provides +/// Entity Framework Core integration for data persistence and change tracking. +/// +/// +/// +/// +/// +/// +[Route("tables/todoitems")] +public class TodoItemsController : TableController +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that provides access to the todo items database. + /// This context is used to create the underlying + /// for data access operations. + /// + /// + /// + /// The constructor configures the controller with an + /// that uses the provided for Entity Framework Core operations. + /// This setup enables the controller to perform database operations while maintaining + /// compatibility with the datasync framework's requirements. + /// + /// + /// The repository handles all CRUD operations, change tracking, and optimistic concurrency + /// control through Entity Framework Core, while the base + /// provides the REST API surface and OData query capabilities. + /// + /// + /// + /// Thrown when is null. + /// + public TodoItemsController(TodoContext context) : base() + { + Repository = new EntityTableRepository(context); + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoContext.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoContext.cs new file mode 100644 index 0000000..eec94dc --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoContext.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.EntityFrameworkCore; + +namespace TodoApp.BlazorWasm.Server.Database; + +/// +/// Represents the Entity Framework Core database context for the Todo application. +/// This class manages the database connection and provides access to the TodoItems table +/// for the Blazor WebAssembly server-side data operations. +/// +/// +/// +/// The class is configured to use an in-memory database for development +/// and testing purposes. It inherits from and provides the necessary +/// infrastructure for Entity Framework Core operations including database creation, data seeding, +/// and change tracking. +/// +/// +/// This context is registered as a service in the dependency injection container and is used +/// by the datasync framework to provide real-time synchronization capabilities between +/// server and client applications. +/// +/// +/// The options to be used by the . +/// +/// +/// +public class TodoContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Gets the that can be used to query and save instances of . + /// + /// + /// A that provides access to the TodoItems table in the database. + /// + /// + /// This property represents the TodoItems table in the database and provides methods + /// for querying, adding, updating, and deleting todo items. The DbSet is automatically + /// configured by Entity Framework Core based on the entity definition. + /// + public DbSet TodoItems => Set(); + + /// + /// Initializes the database by ensuring it exists and populating it with sample data if empty. + /// This method is typically called during application startup to prepare the database for use. + /// + /// + /// A representing the asynchronous database initialization operation. + /// + /// + /// + /// This method performs the following operations: + /// + /// Ensures the database is created using . + /// Checks if the TodoItems table is empty using . + /// If empty, clears the change tracker to avoid entity tracking conflicts. + /// Adds three sample todo items with predefined titles. + /// Saves the sample data to the database. + /// + /// + /// + /// The sample data includes three todo items with titles related to learning Blazor WebAssembly, + /// building applications, and deployment. All sample items are created with their default + /// completion status (not completed). + /// + /// + /// The method uses before adding sample data to prevent + /// entity tracking conflicts that could occur if entities with duplicate keys are being tracked. + /// + /// + /// + /// Thrown when database operations fail, such as when the database cannot be created + /// or when saving changes fails due to validation or constraint violations. + /// + /// + /// Thrown when an error occurs while saving changes to the database during the sample data insertion. + /// + public async Task InitializeDatabaseAsync() + { + _ = await Database.EnsureCreatedAsync(); + + // Add some sample data if the database is empty + if (!await TodoItems.AnyAsync()) + { + // Clear any existing tracked entities to avoid conflicts + ChangeTracker.Clear(); + + TodoItem[] sampleItems = + [ + new TodoItem { Title = "Learn Blazor WASM" }, + new TodoItem { Title = "Build awesome apps" }, + new TodoItem { Title = "Deploy to production" } + ]; + + TodoItems.AddRange(sampleItems); + _ = await SaveChangesAsync(); + } + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoItem.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoItem.cs new file mode 100644 index 0000000..090e00f --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Database/TodoItem.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace TodoApp.BlazorWasm.Server.Database; + +/// +/// Represents a todo item entity for the server-side database in the Blazor WebAssembly application. +/// This class inherits from to provide datasync capabilities +/// with the CommunityToolkit.Datasync.Server framework. +/// +/// +/// +/// This entity class is used by Entity Framework Core for database operations and by the +/// datasync framework for synchronization with client applications. It includes automatic +/// ID generation and validation attributes to ensure data integrity. +/// +/// +/// The class is mapped to a database table and exposed through the TodoItemsController +/// for RESTful API operations and real-time synchronization. +/// +/// +/// +/// +public class TodoItem : EntityTableData +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The constructor automatically generates a unique identifier using a GUID + /// formatted as a 32-character hexadecimal string without hyphens (format "N"). + /// This ensures each todo item has a unique ID when created. + /// + public TodoItem() : base() + { + Id = Guid.NewGuid().ToString("N"); + } + + /// + /// Gets or sets the title or description of the todo item. + /// + /// + /// The title text of the todo item. Must be between 1 and 255 characters in length. + /// + /// + /// + /// This property is required and cannot be null or empty. The validation attributes + /// ensure that the title has a minimum length of 1 character and does not exceed + /// 255 characters to maintain database compatibility and user experience. + /// + /// + /// The title represents the main content or description of what needs to be accomplished + /// in this todo item. + /// + /// + /// + /// Thrown during model validation if the title is null, empty, or exceeds the maximum length. + /// + [Required, StringLength(255, MinimumLength = 1)] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the todo item has been completed. + /// + /// + /// true if the todo item is completed; otherwise, false. + /// The default value is false for newly created items. + /// + /// + /// This boolean flag tracks the completion status of the todo item. + /// When set to true, it indicates that the task has been finished. + /// This property is commonly used for filtering and displaying items in different states. + /// + public bool Completed { get; set; } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Program.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Program.cs new file mode 100644 index 0000000..03c8a9c --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Program.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using Microsoft.EntityFrameworkCore; +using TodoApp.BlazorWasm.Server.Database; + +#pragma warning disable IDE0058 // Expression value is never used + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddDbContext(options => +{ + options.UseInMemoryDatabase("TodoAppDb"); + if (builder.Environment.IsDevelopment()) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + } +}); + +builder.Services.AddDatasyncServices(); +builder.Services.AddControllersWithViews(); +builder.Services.AddRazorPages(); + +WebApplication app = builder.Build(); + +// Initialize the database +using (IServiceScope scope = app.Services.CreateScope()) +{ + TodoContext context = scope.ServiceProvider.GetRequiredService(); + await context.InitializeDatabaseAsync(); +} + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseBlazorFrameworkFiles(); +app.UseStaticFiles(); + +app.UseRouting(); + +app.MapRazorPages(); +app.MapControllers(); +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Properties/launchSettings.json b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Properties/launchSettings.json new file mode 100644 index 0000000..a010904 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17777", + "sslPort": 44325 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5088", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7088;http://localhost:5088", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/TodoApp.BlazorWasm.Server.csproj b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/TodoApp.BlazorWasm.Server.csproj new file mode 100644 index 0000000..c7c79bb --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/TodoApp.BlazorWasm.Server.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.Development.json b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.Development.json new file mode 100644 index 0000000..770d3e9 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.json b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/DatasyncDto.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/DatasyncDto.cs new file mode 100644 index 0000000..a88245b --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/DatasyncDto.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace TodoApp.BlazorWasm.Shared.Models; + +/// +/// Represents a data transfer object (DTO) for a datasync client item in the application. +/// This class is used for communication between the client and server, containing all necessary +/// properties for datasync operations with the CommunityToolkit.Datasync framework. +/// +/// +/// This DTO includes standard datasync properties such as timestamps, version control, and +/// soft delete functionality to support offline synchronization scenarios. +/// +public abstract class DatasyncDto +{ + /// + /// Gets or sets the unique identifier for the todo item. + /// + /// + /// A string representing the unique ID of the todo item. This is typically a GUID + /// converted to string format for compatibility with datasync operations. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the date and time when the todo item was created. + /// + /// + /// A representing when the item was first created. + /// This timestamp includes timezone information for accurate synchronization across different time zones. + /// + /// + /// This property is automatically managed by the datasync framework and should not + /// be manually modified in most scenarios. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the date and time when the todo item was last updated. + /// + /// + /// A representing the most recent modification time. + /// This timestamp includes timezone information for accurate synchronization across different time zones. + /// + /// + /// This property is automatically updated by the datasync framework whenever + /// the entity is modified and should not be manually set in most scenarios. + /// + public DateTimeOffset UpdatedAt { get; set; } + + /// + /// Gets or sets the version identifier used for optimistic concurrency control. + /// + /// + /// A string representing the current version of the entity, typically used as an ETag + /// for conflict resolution during synchronization operations. + /// + /// + /// This property is managed by the datasync framework to handle concurrent updates + /// and ensure data consistency. It should not be manually modified. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the todo item has been soft-deleted. + /// + /// + /// true if the item has been marked for deletion but not physically removed; + /// otherwise, false. + /// + /// + /// This property supports soft delete functionality in the datasync framework, + /// allowing items to be marked as deleted without physically removing them from storage. + /// This enables proper synchronization of delete operations across clients. + /// + public bool Deleted { get; set; } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/TodoItemDto.cs b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/TodoItemDto.cs new file mode 100644 index 0000000..0785934 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/Models/TodoItemDto.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel.DataAnnotations; + +namespace TodoApp.BlazorWasm.Shared.Models; + +/// +/// Represents a data transfer object (DTO) for a todo item in the Blazor WebAssembly application. +/// This class is used for communication between the client and server, containing all necessary +/// properties for datasync operations with the CommunityToolkit.Datasync framework. +/// +public class TodoItemDto : DatasyncDto +{ + /// + /// Gets or sets the title or description of the todo item. + /// + /// + /// The title text of the todo item. Must be between 1 and 255 characters in length. + /// + /// + /// This property is required and has validation attributes to ensure the title + /// is not empty and does not exceed the maximum length limit. + /// + [Required, StringLength(255, MinimumLength = 1)] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the todo item has been completed. + /// + /// + /// true if the todo item is completed; otherwise, false. + /// + public bool Completed { get; set; } +} diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/TodoApp.BlazorWasm.Shared.csproj b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/TodoApp.BlazorWasm.Shared.csproj new file mode 100644 index 0000000..9623deb --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.Shared/TodoApp.BlazorWasm.Shared.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.sln b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.sln new file mode 100644 index 0000000..1773cc8 --- /dev/null +++ b/samples/todoapp-blazor-wasm/TodoApp.BlazorWasm.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.BlazorWasm.Server", "TodoApp.BlazorWasm.Server\TodoApp.BlazorWasm.Server.csproj", "{8B5CA653-AD19-435C-BB5E-85BDFC94F3DC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.BlazorWasm.Client", "TodoApp.BlazorWasm.Client\TodoApp.BlazorWasm.Client.csproj", "{92334429-9A41-4C87-8CAF-905119CE4946}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.BlazorWasm.Shared", "TodoApp.BlazorWasm.Shared\TodoApp.BlazorWasm.Shared.csproj", "{C356C681-A691-4E3F-9D66-3716F5916C5E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8B5CA653-AD19-435C-BB5E-85BDFC94F3DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B5CA653-AD19-435C-BB5E-85BDFC94F3DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B5CA653-AD19-435C-BB5E-85BDFC94F3DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B5CA653-AD19-435C-BB5E-85BDFC94F3DC}.Release|Any CPU.Build.0 = Release|Any CPU + {92334429-9A41-4C87-8CAF-905119CE4946}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92334429-9A41-4C87-8CAF-905119CE4946}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92334429-9A41-4C87-8CAF-905119CE4946}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92334429-9A41-4C87-8CAF-905119CE4946}.Release|Any CPU.Build.0 = Release|Any CPU + {C356C681-A691-4E3F-9D66-3716F5916C5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C356C681-A691-4E3F-9D66-3716F5916C5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C356C681-A691-4E3F-9D66-3716F5916C5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C356C681-A691-4E3F-9D66-3716F5916C5E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal