LoginSignup
2
3

More than 1 year has passed since last update.

Blazor Serverで作る資産管理アプリ

Last updated at Posted at 2023-03-14

概要

Blazor Server を使って資産管理アプリを開発する手順を掲載していきます。資産データはMS SQL Serverのデータベースを使います。

Blazor WebAssemblyでも良いのですが、個人的にクライアント側でダウンロードされるファイルのハッシュ値が合わないというバグを回避できなくなる経験をしたので、クライアント側でのダウンロードファイルが少ないBlazor Serverを選んでいます。

アプリの仕様は以下のとおりです。

  • データベースからデータを取得し、ブラウザ上でグリッド形式で表示する
  • グリッド上から個別のレコード追加・編集画面をモーダルで表示する。
  • CSVファイルでの一括レコード追加・編集を可能にする。
  • レコード編集時にその編集内容の検証(Validation)を行う。
  • 検証(Validation)エラーがあれば画面に表示する。
  • 追加・編集の履歴をデータベースに残し、それを個別のビューで表示する。

開発環境

  • M1 MacBook Air
  • Visual Studio for Mac 17.5
  • Docker Desktop 4.14.1 (91661)
  • Azure Data Studio 1.42.0
  • .NET 7

アプリに追加でインストールするパッケージ

以下のパッケージをNuGetパッケージマネージャからインストールします。ちなみに「Microsoft.AspNetCore.Components.DataAnnotations.Validation」は experimental ですが、このチュートリアルの内容であれば特に問題なく使えています。
「Radzen.Blazor」は主にUI周りのコンポーネントを提供してくれるパッケージです。
「CsvHelper」は.csvファイルの読み込みや書き出しを簡単にしてくれるパッケージです。
「Toolbelt.Blazor.FileDropZone」はファイルのデータを取り込む際に、UI上にファイルをドラッグできる範囲を簡単に実装できるパッケージです。

  • Microsoft.AspNetCore.Components.DataAnnotations.Validation *prerelease
  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.SqlServer
  • Microsoft.EntityFrameworkCore.Tool
  • Radzen.Blazor
  • CsvHelper
  • Toolbelt.Blazor.FileDropZone

最初の準備

Visial Studio を起動したら、Blazor Server アプリのテンプレートを使用してプロジェクトを新規で作成します。プロジェクト名は「BlazorServer1」としましたが、なんでも構いません。

データベースについて今回は Docker Desktop アプリにて、「mcr.microsoft.com/azure-sql-edge:latest」のイメージを用意して、オンプレミスのSQL Serverに近い環境をMac上で構築しました。

詳細は割愛します。

モデルクラスの作成

ModelsフォルダをBlazorServer1プロジェクトフォルダ直下に作成し、その中にモデルクラスの.csファイルを作成していきます。

モデルクラスを先にコーディングして、それを基にデータベースのテーブルを作成する、いわゆる「コードファースト」の手法で進めます。資産管理に必要なテーブルを想定してモデルを作成します。モデルクラスには、データベースのテーブルに必要な列をプロパティとして定義します。

また、今回はAssetクラスを親、Usage、Ticket、Leaseの三つを子として、それぞれ一対一のリレーションシップを構成することを想定しています。今回はAssetCodeというプロパティをキーにしてリレーションシップを形成します。ですので、子となるモデルの方にも外部キーとしてAssetCodeのプロパティを用意する必要があります。ただし、資産データの変更履歴を保存するための「UpdateHistory」クラスだけはリレーションシップなしで作成します。

このコードは、Blazor Serverアプリの資産管理機能に必要なデータモデルを定義しています。Assetクラスは、資産のデータを保持し、それぞれの資産には使用状況(Usage)、チケット(Ticket)、リース(Lease)の情報を持つことができます。Assetクラスには、機体番号(AssetCode)、所有者(Owner)、シリアル番号(SerialNumber)などのフィールドがあります。Usageクラスは、使用状況に関する情報を保持し、ステータス(State)や備考(Note)などのフィールドがあります。他のクラスについては、同様にデータを保持します。

このコードは、CsvHelperを使用してCSVファイルのエクスポート/インポートにも対応しています。BlazorのEditformコンポーネントで役に立つValidation用のAnnotationsもつけています(例えばRequired、maxLength)。さらに、csvファイルでデータを更新する際にファイルのデータを各モデルの適切なプロパティにマッピングするためにCsvHelperのClassMapクラスを使用しています。

Assetモデル

Asset.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;
using CsvHelper.TypeConversion;

namespace BlazorServer1.Models
{
    [Table("Asset", Schema = "dbo")]
    public class Asset
	{
        [Key]
        [Name("ID")]
        public int Id { get; set; }
        
        [Required(ErrorMessage = "機体番号が未入力です")]
        [MaxLength(16, ErrorMessage = "機体番号は16文字以内で入力してください。")]
        [Display(Name = "機体番号")]
        public string AssetCode { get; set; }

        [Required(ErrorMessage = "ステータスは必須です")]
        [MaxLength(16, ErrorMessage = "16文字以内で入力してください。")]
        [Display(Name = "ステータス")]
        public string State { get; set; }

        [Required(ErrorMessage = "所有元が未入力です")]
        [MaxLength(16, ErrorMessage = "所有元は16文字以内で入力してください。")]
        [Display(Name = "所有元")]
        public string? Owner { get; set; }

        [MaxLength(32, ErrorMessage = "シリアル番号は32文字以内で入力してください。")]
        [Display(Name = "シリアル番号")]
        public string? SerialNumber { get; set; }

        [ValidateComplexType]
        public Usage? Usage { get; set; }
        [ValidateComplexType]
        public Ticket? Ticket { get; set; }
        [ValidateComplexType]
        public Lease? Lease { get; set; }
        //public List<Update>? Updates { get; set; }

        // Deep copy
        public Asset Clone()
        {
            return new Asset
            {
                Id = Id,
                AssetCode = AssetCode,
                State = State,
                Owner = Owner,
                SerialNumber = SerialNumber,
                Usage = Usage?.Clone(),
                Ticket = Ticket?.Clone(),
                Lease = Lease?.Clone()
            };
        }
    }

    public sealed class AssetMap : ClassMap<Asset>
    {
        public AssetMap(string? category)
        {
            Map(t => t.Id).Ignore();
            Map(t => t.AssetCode).Name("機体番号");
            Map(t => t.State).Name("ステータス");
            Map(t => t.Owner).Name("所有元");
            Map(t => t.SerialNumber).Name("シリアル番号");

            References<TicketMap>(t => t.Ticket);
            References<UsageMap>(t => t.Usage);
            if (category == "leased")
            {
                References<LeaseMap>(t => t.Lease);
            }
        }
    }
}

Usageモデル

Usage.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;

namespace BlazorServer1.Models
{
	[Table("Usage", Schema = "dbo")]
    public class Usage
	{
		[Key]
		public int Id { get; set; }
        public string AssetCode { get; set; }

        [Display(Name = "故障")]
        public bool? State { get; set; }

        [MaxLength(256, ErrorMessage = "備考は256文字以内で入力してください。")]
        [Display(Name = "備考")]
        public string? Note { get; set; }

        public Asset? Asset { get; set; }

        // Deep copy
        public Usage Clone()
        {
            return new Usage
            {
                Id = Id,
                AssetCode = AssetCode,
                State = State,
                Note = Note
            };
        }
    }

    public sealed class UsageMap : ClassMap<Usage>
    {
        public UsageMap()
        {
            Map(t => t.Id).Ignore();
            Map(t => t.AssetCode).Ignore();
            Map(t => t.State).Name("故障");
            Map(t => t.Note).Optional().Name("備考");
        }
    }
}

Ticketモデル

Ticket.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;
using CsvHelper.TypeConversion;

namespace BlazorServer1.Models
{
	[Table("Tickets", Schema = "dbo")]
    public class Ticket : IValidatableObject
	{
		[Key]
        public int Id { get; set; }
        public string AssetCode { get; set; }

        [MaxLength(16, ErrorMessage = "チケット番号は32文字以内で入力してください。")]
        [Display(Name = "チケット番号")]
        public string? TicketId { get; set; }

        [DataType(DataType.Date)]
        [Display(Name = "チケット作成日")]
        public DateTime? ApplyDate { get; set; }

        public Asset? Asset { get; set; }

        // Validation
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            var ticket = (Ticket)validationContext.ObjectInstance;

            if (ticket.ApplyDate > DateTime.Now)
            {
                yield return new ValidationResult(
                    "チケット作成日に未来の日付を入力しないでください。",
                    new[] { nameof(ticket.ApplyDate) });
            }

            if (!string.IsNullOrEmpty(ticket.TicketId) && ApplyDate == null)
            {
                yield return new ValidationResult(
                    "チケット番号を入力した場合はチケット作成日も入力してください。",
                    new[] { nameof(ticket.ApplyDate) });
            }
        }

        // Deep copy
        public Ticket Clone()
        {
            return new Ticket
            {
                Id = Id,
                AssetCode = AssetCode,
                TicketId = TicketId,
                ApplyDate = ApplyDate,
            };
        }
    }

    public sealed class TicketMap : ClassMap<Ticket>
    {
        public TicketMap()
        {
            Map(m => m.Id).Ignore();
            Map(m => m.AssetCode).Ignore();
            Map(m => m.TicketId).Name("チケット番号");
            Map(m => m.ApplyDate).Name("チケット作成日");
        }
    }
}

Leaseモデル

Lease.cs
using System;
using CsvHelper.Configuration;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using CsvHelper.Configuration.Attributes;
using CsvHelper.TypeConversion;

namespace BlazorServer1.Models
{
    [Table("Lease", Schema = "dbo")]
    public class Lease
    {
        [Key]
        public int Id { get; set; }
        public string AssetCode { get; set; }

        [Required(ErrorMessage = "レンタル管理番号が未入力です。")]
        [MaxLength(32)]
        [Display(Name = "レンタル管理番号")]
        public string? ReferenceId { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "yyyy/MM/dd")]
        [Display(Name = "レンタル終了日")]
        public DateTime? EndDate { get; set; }

        public Asset? Asset { get; set; }

        // Deep copy
        public Lease Clone()
        {
            return new Lease
            {
                Id = Id,
                AssetCode = AssetCode,
                ReferenceId = ReferenceId,
                EndDate = EndDate,
            };
        }
    }

    public sealed class LeaseMap : ClassMap<Lease>
    {
        public LeaseMap()
        {
            Map(t => t.Id).Ignore();
            Map(t => t.AssetCode).Ignore();
            Map(l => l.ReferenceId).Name("レンタル管理番号");
            Map(l => l.EndDate).Name("レンタル終了日");
        }
    }
}

UpdateHistoryモデル

このモデルは、変更履歴用のモデルで、ただひたすら変更履歴を残すのみなので他のモデルとのリレーションシップはありません。

UpdateHistory.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using CsvHelper.TypeConversion;

namespace BlazorServer1.Models
{
    [Table("UpdateHistory", Schema = "dbo")]
    public class UpdateHistory
	{
        [Key]
        public int Id { get; set; }

        [MaxLength(16)]
        [Display(Name = "機体番号")]
        public string AssetCode { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "yyyy/MM/dd")]
        [Display(Name = "更新日時")]
        public DateTime? UpdateTime { get; set; }

        [MaxLength(16)]
        [Display(Name = "更新者")]
        public string? Updater { get; set; }

        [MaxLength(16)]
        [Display(Name = "変更の種類")]
        public string? Action { get; set; }

        [MaxLength(32)]
        [Display(Name = "変更した列")]
        public string? ChangedColumn { get; set; }

        [MaxLength(256)]
        [Display(Name = "変更前の値")]
        public string? OldValue { get; set; }

        [MaxLength(256)]
        [Display(Name = "変更後の値")]
        public string? NewValue { get; set; }
    }
}

DbContextクラスを作成する

Entity Framework Coreはいわゆる.NETフレームワークで開発する際に一般的に使用されるMicrosoft製のORMです。このパッケージを利用していきます。

DataフォルダをBlazorServer1フォルダ直下に作成します。

DbContextクラスを継承したAppDbContextクラスを作成しています。これがアプリ上のデータベースの分身のようなものになります。さらにDbSet型のプロパティをデータベースに用意する予定のテーブルの数だけ定義します。これらはアプリ上でのデータベースのテーブルのようなものです。

OnModelCreating()メソッド内でモデル生成時にそれぞれのDbSetのリレーションシップを形成しています。

AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using BlazorServer1.Models;

namespace BlazorServer1.Data
{
	public class AppDbContext : DbContext
	{
		public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
		{
		}
        
        public DbSet<Asset> Assets { get; set; }
        public DbSet<Usage> Usages { get; set; }
		public DbSet<Ticket> Tickets { get; set; }
		public DbSet<Lease> Leases { get; set; }
		public DbSet<UpdateHistory> UpdateHistories { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
		{
			modelBuilder.Entity<Ticket>()
				.HasOne(a => a.Asset)
				.WithOne(t => t.Ticket)
				.HasForeignKey<Ticket>(a => a.AssetCode)
				.HasPrincipalKey<Asset>(t => t.AssetCode);

			modelBuilder.Entity<Lease>()
				.HasOne(l => l.Asset)
				.WithOne(a => a.Lease)
				.HasForeignKey<Lease>(l => l.AssetCode)
                .HasPrincipalKey<Asset>(a => a.AssetCode);

            modelBuilder.Entity<Usage>()
				.HasOne(a => a.Asset)
				.WithOne(u => u.Usage)
				.HasForeignKey<Usage>(u => u.AssetCode)
				.HasPrincipalKey<Asset>(a => a.AssetCode);
		}
    }
}

データベースの接続文字列を設定

appsettings.jsonファイルに接続文字列を設定します。これで実際のデータベースと接続できるようになります。

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=SampleDB;Encrypt=false;User ID=YourId;Password=YourPassword;"
  }
}

さらにprogram.csファイルにAppDbContextクラスをデータベースに接続するためのコードを追加します。

サービスの追加

ついでですが、このあと用意するAssetServiceクラスもサービスとして追加しておきます(Your serviceとコメントしている箇所)。さらについでに、サービスクラスができた後のUI部分で使用するRadzen Blazorの各種サービスも追加しておきます(Radzen servicesとコメントしている箇所)。

program.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using BlazorServer1.Data;
using BlazorServer1.Services;
using Microsoft.EntityFrameworkCore;
using Radzen;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Default Sample service
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<WeatherForecastService>();

// Database connection service
builder.Services.AddDbContext<AppDbContext>(
    options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Your service
builder.Services.AddScoped<AssetService>();

// Radzen services
builder.Services.AddScoped<DialogService>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<TooltipService>();
builder.Services.AddScoped<ContextMenuService>();


var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();

app.UseRouting();

app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

app.Run();

コードファーストでデータベースのテーブル作成

ここまでで、データベースのテーブルを作成するのに必要なコーディングができたので、ここまでに記述したコードを基にデータベースのテーブルを作成します。事前にSQLサーバーが稼働している必要があります(この記事の環境の場合はDocker Desktopの該当コンテナーを起動します)。

ターミナルにて、プロジェクトのパスから以下のコマンドを実行します。
dotnet ef migrations add initialCreate

なお、initialCreateの部分はなんでも構いません。お好みの名前をつけるだけです。このコマンドにより、データベースのテーブルを作成するためのファイルが生成されます。プロジェクトには自動的にMigrationsというフォルダが作成され、その中に20230330172754_initialCreate.csという感じの名前のファイルが作成されているはずです。

続けて以下のコマンドを実行し、上記ファイルからデータベースにテーブルを作成します。
dotnet ef database update

テーブルが期待通りに生成されたかを確認します(私の場合はAzure Data Studioを使って確認します)。

サービスクラスを作成する

プロジェクトにServicesフォルダを追加し、その中にAssetService.csクラスのファイルを用意します。このサービスクラスでは、先に用意したAppDbContextクラスのメソッドを利用していわゆるCRUDのメソッドを定義していきます。

CsvHelperパッケージを使って、CSVファイルをアップロードしての一括データ変更用のメソッドも定義しています。CSVファイルからデータを取り込む際にもおかしなデータが反映されないように各モデルクラスで定義しているValidationを行うようにしています。

AssetService.cs
using System;
using System.Globalization;
using System.ComponentModel.DataAnnotations;
using BlazorServer1.Data;
using BlazorServer1.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using CsvHelper;
using CsvHelper.TypeConversion;
using CsvHelper.Configuration;
using Microsoft.SqlServer.Server;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using Microsoft.EntityFrameworkCore.Metadata.Internal;


namespace BlazorServer1.Services
{
    public class AssetService
    {
        private readonly AppDbContext _context;

        public AssetService(AppDbContext context)
        {
            _context = context;
        }

        public async Task<List<Asset>> GetAssets()
        {
            return await _context.Assets
                .AsNoTracking()
                .Include(a => a.Usage)
                .Include(u => u.Ticket)
                .Include(a => a.Lease)
                .OrderByDescending(a => a.Id)
                .ToListAsync();
        }

        public async Task<Asset?> GetAsset(string? assetCode)
        {
            var asset = await _context.Assets
                //.AsNoTracking()
                .Include(a => a.Usage)
                .Include(u => u.Ticket)
                .Include(a => a.Lease)
                .FirstOrDefaultAsync(a => a.AssetCode == assetCode);
            return asset;
        }

        // Get all leased assets
        public async Task<List<Asset>> GetLeasedAssets()
        {
            return await _context.Assets
                .AsNoTracking()
                .Include(a => a.Usage)
                .Include(u => u.Ticket)
                .Include(a => a.Lease)
                .Where(a => a.Owner == "リース")
                .OrderByDescending(a => a.Id)
                .ToListAsync();
        }
        // Get a single leased asset
        public async Task<Asset?> GetLeasedAsset(int? id)
        {
            return await _context.Assets
                //.AsNoTracking()
                .Include(a => a.Usage)
                .Include(u => u.Ticket)
                .Include(a => a.Lease)
                .Where(a => a.Owner == "リース")
                .FirstOrDefaultAsync(a => a.Id == id);
        }

        // Get all purchased assets
        public async Task<List<Asset>> GetPurchasedAssets()
        {
            return await _context.Assets
                .AsNoTracking()
                .Include(a => a.Usage)
                .Where(a => a.Owner == "社内購入")
                .OrderByDescending(a => a.Id)
                .ToListAsync();
        }
        // Get a single leased asset
        public async Task<Asset?> GetPurchasedAsset(int? id)
        {
            return await _context.Assets
                //.AsNoTracking()
                .Include(a => a.Usage)
                .Where(a => a.Owner == "社内購入")
                .FirstOrDefaultAsync(a => a.Id == id);
        }

        // Get all update history
        public async Task<List<UpdateHistory>> GetUpdateHistory()
        {
            return await _context.UpdateHistories
                .OrderByDescending(a => a.UpdateTime)
                .ToListAsync();
        }

        // Helper: Update History
        public void AddUpdateHistory(bool isNew, string AssetCode, DateTime time, string updater, string? column, string? oldValue, string? newValue)
        {
            string action = isNew ? "ADD" : "UPDATE";

            var history = new UpdateHistory
            {
                AssetCode = AssetCode,
                UpdateTime = time,
                Updater = updater,
                Action = action,
                ChangedColumn = column,
                OldValue = isNew ? null : oldValue,
                NewValue = newValue
            };

            _context.UpdateHistories.Add(history);
        }

        // Add a single asset
        public void AddAsset(Asset newAsset)
        {
            try
            {
                var isNew = true;
                var assetCode = newAsset.AssetCode;
                if (assetCode == null)
                    return;
                var now = DateTime.Now;
                var updater = "Me";

                _context.Assets.Add(newAsset);

                foreach (var prop in newAsset.GetType().GetProperties())
                {
                    if (prop != null)
                    {
                        var newValue = prop.GetValue(newAsset)?.ToString();
                        //AddUpdateHistory(isNew, assetCode, now, updater, prop.Name, null, newValue);
                        var attributes = prop.GetCustomAttributes(typeof(DisplayAttribute), true);
                        if (attributes.Length > 0)
                        {
                            var column = ((DisplayAttribute)attributes[0]).Name;
                            AddUpdateHistory(isNew, assetCode, now, updater, column, null, newValue);
                        }
                    }
                }

                if (newAsset.Usage != null)
                {
                    var newUsage = newAsset.Usage;
                    _context.Usages.Add(newUsage);

                    foreach (var prop in newUsage.GetType().GetProperties())
                    {
                        if (prop != null)
                        {
                            var newValue = prop.GetValue(newUsage)?.ToString();
                            //AddUpdateHistory(isNew, assetCode, now, updater, prop.Name, null, newValue);
                            var attributes = prop.GetCustomAttributes(typeof(DisplayAttribute), true);
                            if (attributes.Length > 0)
                            {
                                var column = ((DisplayAttribute)attributes[0]).Name;
                                AddUpdateHistory(isNew, assetCode, now, updater, column, null, newValue);
                            }
                        }
                    }
                }

                if (newAsset.Ticket != null)
                {
                    var newTicket = newAsset.Ticket;
                    _context.Tickets.Add(newTicket);

                    foreach (var prop in newTicket.GetType().GetProperties())
                    {
                        if (prop != null)
                        {
                            var newValue = prop.GetValue(newTicket)?.ToString();
                            //AddUpdateHistory(isNew, assetCode, now, updater, prop.Name, null, newValue);
                            var attributes = prop.GetCustomAttributes(typeof(DisplayAttribute), true);
                            if (attributes.Length > 0)
                            {
                                var column = ((DisplayAttribute)attributes[0]).Name;
                                AddUpdateHistory(isNew, assetCode, now, updater, column, null, newValue);
                            }
                        }
                    }
                }

                if (newAsset.Lease != null)
                {
                    var newLease = newAsset.Lease;
                    _context.Leases.Add(newLease);

                    foreach (var prop in newLease.GetType().GetProperties())
                    {
                        if (prop != null)
                        {
                            var newValue = prop.GetValue(newLease)?.ToString();
                            //AddUpdateHistory(isNew, assetCode, now, updater, prop.Name, null, newValue);
                            var attributes = prop.GetCustomAttributes(typeof(DisplayAttribute), true);
                            if (attributes.Length > 0)
                            {
                                var column = ((DisplayAttribute)attributes[0]).Name;
                                AddUpdateHistory(isNew, assetCode, now, updater, column, null, newValue);
                            }
                        }
                    }
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                throw;
            }
        }

        // Save adding a single asset
        public async Task SaveAddingAsset(Asset asset)
        {
            AddAsset(asset);
            await _context.SaveChangesAsync();
        }


        // Update a single asset
        public void UpdateAsset(Asset asset)
        {
            try
            {
                bool isNew = false;
                if (asset.AssetCode == null) return;
                var assetCode = asset.AssetCode;
                var now = DateTime.Now;
                var updater = "Me";

                var entryAsset = _context.Entry(asset);
                entryAsset.State = EntityState.Modified;

                var oldValues = entryAsset.OriginalValues;
                var newValues = entryAsset.CurrentValues;

                foreach (var prop in oldValues.Properties)
                {
                    var oldValue = oldValues[prop]?.ToString();
                    var newValue = newValues[prop]?.ToString();
                    var attributes = asset.GetType().GetProperty(prop.Name)?.GetCustomAttributes(typeof(DisplayAttribute), true);
                    if (oldValue != newValue && attributes != null && attributes.Length > 0)
                    {
                        var column = ((DisplayAttribute)attributes[0]).Name;
                        AddUpdateHistory(isNew, assetCode, now, updater, column, oldValue, newValue);
                    }
                }

                if (asset.Usage != null)
                {
                    var entryUsage = _context.Entry(asset.Usage);
                    entryUsage.State = EntityState.Modified;

                    oldValues = entryUsage.OriginalValues;
                    newValues = entryUsage.CurrentValues;

                    foreach (var prop in oldValues.Properties)
                    {
                        var oldValue = oldValues[prop]?.ToString();
                        var newValue = newValues[prop]?.ToString();
                        var attributes = asset.Usage.GetType().GetProperty(prop.Name)?.GetCustomAttributes(typeof(DisplayAttribute), true);
                        if (oldValue != newValue && attributes != null && attributes.Length > 0)
                        {
                            var column = ((DisplayAttribute)attributes[0]).Name;
                            AddUpdateHistory(isNew, assetCode, now, updater, column, oldValue, newValue);
                        }
                    }
                }

                if (asset.Ticket != null)
                {
                    var entryTicket = _context.Entry(asset.Ticket);
                    entryTicket.State = EntityState.Modified;

                    oldValues = entryTicket.OriginalValues;
                    newValues = entryTicket.CurrentValues;

                    foreach (var prop in oldValues.Properties)
                    {
                        var oldValue = oldValues[prop]?.ToString();
                        var newValue = newValues[prop]?.ToString();
                        var attributes = asset.Ticket.GetType().GetProperty(prop.Name)?.GetCustomAttributes(typeof(DisplayAttribute), true);
                        if (oldValue != newValue && attributes != null && attributes.Length > 0)
                        {
                            var column = ((DisplayAttribute)attributes[0]).Name;
                            AddUpdateHistory(isNew, assetCode, now, updater, column, oldValue, newValue);
                        }
                    }
                }

                if (asset.Lease != null)
                {
                    var entryLease = _context.Entry(asset.Lease);
                    entryLease.State = EntityState.Modified;

                    oldValues = entryLease.OriginalValues;
                    newValues = entryLease.CurrentValues;

                    foreach (var prop in oldValues.Properties)
                    {
                        var oldValue = oldValues[prop]?.ToString();
                        var newValue = newValues[prop]?.ToString();
                        var attributes = asset.Lease.GetType().GetProperty(prop.Name)?.GetCustomAttributes(typeof(DisplayAttribute), true);
                        if (oldValue != newValue && attributes != null && attributes.Length > 0)
                        {
                            var column = ((DisplayAttribute)attributes[0]).Name;
                            AddUpdateHistory(isNew, assetCode, now, updater, column, oldValue, newValue);
                        }
                    }
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                throw;
            }
        }

        // Save updating a single asset
        public async Task SaveUpdatingAsset(Asset asset)
        {
            UpdateAsset(asset);
            await _context.SaveChangesAsync();
        }


        // Add or update multiple assets
        public async Task ImportAssetsData(IBrowserFile file)
        {
            var stream = new MemoryStream();
            await file.OpenReadStream().CopyToAsync(stream);
            stream.Position = 0;

            using (var reader = new StreamReader(stream))
            {
                using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
                {
                    csv.Context.RegisterClassMap(new AssetMap(null));
                    csv.Read();
                    csv.ReadHeader();

                    using (var transaction = await _context.Database.BeginTransactionAsync())
                    {
                        try
                        {
                            string allErrorMessages = "";

                            while (csv.Read())
                            {
                                Asset? asset;
                                string errorMessage = "";
                                bool isValid = true;
                                string category = "";
                                string action = "";

                                // Categorise from the data in .csv file
                                if (csv.GetField("所有元") == "リース")
                                {
                                    category = "leased";
                                }
                                else if (csv.GetField("所有元") == "社内購入")
                                {
                                    category = "purchased";
                                }

                                asset = await GetAsset(csv.GetField("機体番号"));

                                // Determine action by existence of the same asset in database as in csv
                                if (asset != null)
                                {
                                    action = "update";
                                }
                                else
                                {
                                    action = "add";

                                    if (category == "leased")
                                    {
                                        asset = new Asset { Usage = new Usage(), Ticket = new Ticket(), Lease = new Lease() };
                                    }
                                    else if (category == "purchased")
                                    {
                                        asset = new Asset { Usage = new Usage(), Ticket = new Ticket() };
                                    }
                                }

                                if (asset == null) return;

                                asset.AssetCode = csv.GetField("機体番号") ?? "";
                                asset.State = csv.GetField("ステータス") ?? "";
                                asset.Owner = csv.GetField("所有元");
                                asset.SerialNumber = csv.GetField("シリアル番号");

                                // Validation for Asset model
                                var assetValidationContext = new ValidationContext(asset);
                                var assetValidationResults = new List<ValidationResult>();
                                var assetIsValid = Validator.TryValidateObject(asset, assetValidationContext, assetValidationResults, true);
                                if (!assetIsValid)
                                {
                                    isValid = false;
                                    var errorMessages = assetValidationResults.Select(x => x.ErrorMessage).ToList();
                                    errorMessage = string.IsNullOrEmpty(errorMessage) ? string.Join(", ", errorMessages) : errorMessage + string.Concat(", ", string.Join(", ", errorMessages));
                                }

                                if (asset.Ticket != null)
                                {
                                    asset.Ticket.AssetCode = asset.AssetCode;
                                    asset.Ticket.TicketId = csv.GetField("チケット番号");
                                    asset.Ticket.ApplyDate = csv.GetField<DateTime?>("チケット作成日");

                                    // Validation for Ticket model
                                    var ticketValidationContext = new ValidationContext(asset.Ticket);
                                    var ticketValidationResults = new List<ValidationResult>();
                                    var ticketIsValid = Validator.TryValidateObject(asset.Ticket, ticketValidationContext, ticketValidationResults, true);
                                    if (!ticketIsValid)
                                    {
                                        isValid = false;
                                        var errorMessages = ticketValidationResults.Select(x => x.ErrorMessage).ToList();
                                        errorMessage = string.IsNullOrEmpty(errorMessage) ? string.Join(", ", errorMessages) : errorMessage + string.Concat(", ", string.Join(", ", errorMessages));
                                    }
                                }

                                if (asset.Usage != null)
                                {
                                    asset.Usage.AssetCode = asset.AssetCode;
                                    asset.Usage.Fault = csv.GetField<bool?>("故障");
                                    asset.Usage.Note = csv.GetField("備考");

                                    // Validation for Usage model
                                    var usageValidationContext = new ValidationContext(asset.Usage);
                                    var usageValidationResults = new List<ValidationResult>();
                                    var usageIsValid = Validator.TryValidateObject(asset.Usage, usageValidationContext, usageValidationResults, true);
                                    if (!usageIsValid)
                                    {
                                        isValid = false;
                                        var errorMessages = usageValidationResults.Select(x => x.ErrorMessage).ToList();
                                        errorMessage = string.IsNullOrEmpty(errorMessage) ? string.Join(", ", errorMessages) : errorMessage + string.Concat(", ", string.Join(", ", errorMessages));
                                    }
                                }

                                if (asset.Lease != null)
                                {
                                    asset.Lease.AssetCode = asset.AssetCode;
                                    asset.Lease.ReferenceId = csv.GetField("レンタル管理番号");
                                    asset.Lease.EndDate = csv.GetField<DateTime?>("レンタル終了日");

                                    // Validation for Lease model
                                    var leaseValidationContext = new ValidationContext(asset.Lease);
                                    var leaseValidationResults = new List<ValidationResult>();
                                    var leaseIsValid = Validator.TryValidateObject(asset.Lease, leaseValidationContext, leaseValidationResults, true);
                                    if (!leaseIsValid)
                                    {
                                        isValid = false;
                                        var errorMessages = leaseValidationResults.Select(x => x.ErrorMessage).ToList();
                                        errorMessage = string.IsNullOrEmpty(errorMessage) ? string.Join(", ", errorMessages) : errorMessage + string.Concat(", ", string.Join(", ", errorMessages));
                                    }
                                }

                                if (!isValid)
                                {
                                    errorMessage = $"{asset.AssetCode}" + errorMessage;
                                    allErrorMessages = allErrorMessages == "" ? errorMessage : allErrorMessages + "<br />" + errorMessage;
                                }

                                if (action == "update")
                                {
                                    UpdateAsset(asset);
                                }
                                else if (action == "add")
                                {
                                    AddAsset(asset);
                                }
                            }
                            if (allErrorMessages != "")
                            {
                                throw new System.ComponentModel.DataAnnotations.ValidationException($"Validation Error: {allErrorMessages}");
                            }
                            else
                            {
                                await _context.SaveChangesAsync();
                                await transaction.CommitAsync();
                            }
                        }
                        catch (Exception e)
                        {
                            await transaction.RollbackAsync();
                            Console.WriteLine(e.Message);
                            throw;
                        }
                    }
                }
            }
        }
    }
}

UIを作成する

ここからはUIを作成していきます。

_Host.cshtml

Radzen.Blazorパッケージのcssやjsファイルを利用したり、ブラウザ上でのファイルダウンロードにはJavaScriptのメソッドが必要なため、先にデフォルトで用意されている_Host.cshtmlファイルを編集します。

_Host.cshtml
@page "/"
@using Microsoft.AspNetCore.Components.Web
@namespace BlazorServer1.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
    <link href="BlazorServer1.styles.css" rel="stylesheet" />
    <link rel="icon" type="image/png" href="favicon.png" />

    @*Radzen.Blazor*@
    <link rel="stylesheet" href="_content/Radzen.Blazor/css/material-base.css">
    <script src="_content/Radzen.Blazor/Radzen.Blazor.js"></script>

    @*Additional JS Method*@
    <script>window.downloadFileFromStream = async (fileName, contentStreamReference) => {
            const arrayBuffer = await contentStreamReference.arrayBuffer();
            const blob = new Blob([arrayBuffer]);
            const url = URL.createObjectURL(blob);
            const anchorElement = document.createElement('a');
            anchorElement.href = url;
            anchorElement.download = fileName ?? '';
            anchorElement.click();
            anchorElement.remove();
            URL.revokeObjectURL(url);
        }</script>
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
    <component type="typeof(App)" render-mode="ServerPrerendered" />

    <div id="blazor-error-ui">
        <environment include="Staging,Production">
            An error has occurred. This application may no longer respond until reloaded.
        </environment>
        <environment include="Development">
            An unhandled exception has occurred. See browser dev tools for details.
        </environment>
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script src="_framework/blazor.server.js"></script>
</body>
</html>

次に、Radzen.Blazorパッケージを使って、サイト全体をヘッダーとサイドバーのあるページレイアウトにしていきます。デフォルトで用意されているSharedフォルダ内のMainLayout.razorファイルを編集します。ちなみにフィードバックページは作成していないので、リンクはダミーです。

MainLayout.razor
@inherits LayoutComponentBase

@using Radzen.Blazor
@using Radzen

<RadzenLayout>
    <RadzenHeader>
        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0">
            <RadzenSidebarToggle Click="@(() => sidebarExpanded = !sidebarExpanded)" />
            <RadzenLink Text="Asset Manager" Path="/" />
        </RadzenStack>
    </RadzenHeader>
    <RadzenSidebar @bind-Expanded="@sidebarExpanded">
        <RadzenPanelMenu>
            <RadzenPanelMenuItem Text="Asset Viewer" Icon="desktop_mac" Path="assetviewer" />
            <RadzenPanelMenuItem Text="Update History" Icon="desktop_mac" Path="historyviewer" />
        </RadzenPanelMenu>
    </RadzenSidebar>
    <RadzenBody>
        <div class="p-2">
            @Body
        </div>
    </RadzenBody>

    @*dummy link to the feedback page*@
    <RadzenFooter>
        Feel free to send your feedback from <RadzenLink Text="here" Path="feedback" Target="_blank" />
    </RadzenFooter>
</RadzenLayout>

<RadzenDialog />
<RadzenNotification />
<RadzenContextMenu />
<RadzenTooltip />

@code {
    private bool sidebarExpanded = false;
}

今しがたMainLayoutのサイドバーに設置したリンクの通り、以下の二つのページを用意します。

  1. データグリッドで表示された資産管理データを閲覧および編集するAssetViewerページ
  2. 編集した履歴を確認するHistoryViewerページを作成していきます。

さらに 1 のページからはデータの個別編集時または個別追加時にダイアログ表示させるための以下のページも作成します。
3. 編集時にダイアログ形式で表示されるAssetEditorページ

AssetViewer

主にUIコンポーネントは無料で利用可能なサードパーティのRadze.Blazorパッケージを利用します。データを.csvファイルでダウンロードまたはアップロードする機能はCsvHelperパッケージを利用します。

AssetViewer.razor@page "/assetviewer"

@using System.Linq
@using CsvHelper
@using CsvHelper.Configuration
@using System.Globalization
@using System.IO
@using System.Net.Http
@using System.Threading.Tasks

@using Radzen
@using Radzen.Blazor
@using Toolbelt.Blazor.FileDropZone
@using BlazorServer1.Models
@using BlazorServer1.Services

@inject AssetService AssetService
@inject NavigationManager NavigationManager
@inject DialogService DialogService
@inject NotificationService NotificationService
@inject IJSRuntime JS


<div class="row row-cols-auto align-items-center mb-4">
    <div class=" = col">
        <RadzenIcon Icon="desktop_mac" />
    </div>
    <div class=" = col">
        <RadzenText Text="Current Data" TextStyle="TextStyle.DisplayH5" />
    </div>
</div>



@if (assets == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <div class="container-fluid">
        <div class="row">
            <div class="col pt-2">
                <RadzenAccordion Multiple="true">
                    <Items>
                        @*アップデートセクション*@
                        <RadzenAccordionItem Text="Update" Icon="edit">
                            <FileDropZone class="drop-zone">
                                <div class="row align-items-center">
                                    <div class="col-auto mt-0 mb-4">
                                        <label for="upload_inputfile" class="btn btn-primary rz-color-primary rz-background-color-base-100 rz-border-color-base-100 rz-shadow-1 rz-ripple">
                                            <InputFile id="upload_inputfile" style="display: none" Accept=".csv" OnChange=@OnFileSelected class="col" />
                                            <RadzenLabel Text="ファイル選択" class="col" />
                                        </label>
                                    </div>
                                    <div class="col-auto mt-0 mb-4">
                                        <RadzenLabel Text="@(selectedFile?.Name)" class="col" />
                                    </div>
                                </div>
                                <div class="row align-items-center">
                                    <div class="col-auto mb-0">
                                        <RadzenButton Text="アップロード" ButtonStyle="ButtonStyle.Danger" Click=@UploadFile Disabled="@(isUploading || selectedFile == null)" />
                                    </div>
                                </div>
                                @if (uploadErrorMessage != "")
                                {
                                    <RadzenAlert Title="Upload Error" AlertStyle="AlertStyle.Danger" Variant="Variant.Flat" Shade="Shade.Lighter" Text="@uploadErrorMessage"/>
                                }
                            </FileDropZone>
                        </RadzenAccordionItem>

                        @*データグリッドセクション*@
                        <RadzenAccordionItem Text="DataGrid" Icon="grid_view" Selected="true">
                            <div class="row mb-4">
                                <div class="col">
                                    <RadzenSelectBar Data="@categoryList" @bind-Value="@selectedCategory" TValue="string" Change="@OnChangeCategory" />
                                </div>
                            </div>
                            <div class="row align-items-center">
                                @*カラム表示スイッチ*@
                                <div class="col-auto">
                                    <div class="row">
                                        <div class="col-auto mb-4">
                                            <RadzenLabel Text="製品" />
                                            <RadzenSwitch @bind-Value="@showAssetColumns" class="me-2" />
                                        </div>
                                        <div class="col-auto mb-4">
                                            <RadzenLabel Text="利用" />
                                            <RadzenSwitch @bind-Value="@showUsageColumns" class="me-2" />
                                        </div>
                                        @if (selectedCategory == "leased" || selectedCategory == "purchased")
                                        {
                                            <div class="col-auto mb-4">
                                                <RadzenLabel Text="チケット" />
                                                <RadzenSwitch @bind-Value="@showTicketColumns" class="me-2" />
                                            </div>
                                        }
                                        @if (selectedCategory == "leased")
                                        {
                                            <div class="col-auto mb-4">
                                                <RadzenLabel Text="リース" />
                                                <RadzenSwitch @bind-Value="@showLeaseColumns" class="me-2" />
                                            </div>
                                        }
                                    </div>
                                </div>

                                <div class="col"></div>

                                @*ボタン*@
                                <div class="col-auto">
                                    <div class="row">
                                        <div class="col-auto mb-4">
                                            <RadzenButton Text="Copy" Icon="content_paste" Click="@CopySelectedRows" />
                                        </div>
                                        <div class="col-auto mb-4">
                                            <RadzenButton Text="Add" Icon="add" Click="@(args => OpenEditor(null))" />
                                        </div>
                                        <div class="col-auto mb-4">
                                            <RadzenButton Text="Export" Icon="cloud_download" Click=@(args => DownloadCsv()) />
                                        </div>
                                    </div>
                                </div>
                            </div>

                            @*データグリッド*@
                            <RadzenDataGrid AllowColumnPicking="false" AllowColumnResize="true" ColumnWidth="150px" Density="Density.Compact"
                                            AllowPaging="true" PagerHorizontalAlign="HorizontalAlign.Right" PageSize="50"
                                            AllowFiltering="true" FilterMode="FilterMode.Simple" LogicalFilterOperator="LogicalFilterOperator.And" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
                                            AllowSorting="true" SelectionMode="DataGridSelectionMode.Multiple" AllowRowSelectOnRowClick="false"
                                            @ref="grid" Data="@assets" TItem="Asset" @bind-Value="@selectedRows" RowRender="@OnRowRender">
                                <Columns>

                                    @*データグリッドカラム*@
                                    <RadzenDataGridColumn TItem="Asset" Context="Asset" Filterable="false" Sortable="false" TextAlign="TextAlign.Center" Width="64px" Frozen="true">
                                        <Template Context="Asset">
                                            <RadzenButton Icon="edit" ButtonStyle="ButtonStyle.Primary" Variant="Variant.Flat" Size="ButtonSize.Small" Click=@(async args => await OpenEditor(Asset.Id)) @onclick:stopPropagation="false">
                                            </RadzenButton>
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Width="48px" Sortable="false" Filterable="false" Frozen="true">
                                        <HeaderTemplate>
                                            <RadzenCheckBox TriState="false" TValue="bool" Value="@(assets.Any(i => selectedRows != null && selectedRows.Contains(i)))" Change="@(args => selectedRows = args ? assets.ToList() : null)" />
                                        </HeaderTemplate>
                                        <Template Context="data">
                                            <RadzenCheckBox TriState="false" Value="@(selectedRows != null && selectedRows.Contains(data))" TValue="bool" Change=@(() => grid.SelectRow(data)) />
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Type="typeof(IEnumerable<string>)" Property="AssetCode" Title="機体番号" Frozen="true" FilterValue="@selectedAssetCode" FilterOperator="FilterOperator.Contains">
                                        <FilterTemplate>
                                            <RadzenDropDown @bind-Value="@selectedAssetCode" Style="width:100%" Data=@assetCodeList Change="@OnSelectedAssetCodeChange" AllowClear="true" Multiple="true"
                                                            FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                                        </FilterTemplate>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Type="typeof(IEnumerable<string>)" Property="State" Title="ステータス" Frozen="true" FilterValue="@selectedState" FilterOperator="FilterOperator.Contains">
                                        <FilterTemplate>
                                            <RadzenDropDown @bind-Value="@selectedState" Style="width:100%" Data=@stateList Change="@OnSelectedAssetCodeChange" AllowClear="true" Multiple="true"
                                                            FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                                        </FilterTemplate>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Type="typeof(IEnumerable<string>)" Property="Owner" Title="所有元" Frozen="true" FilterValue="@selectedOwner" FilterOperator="FilterOperator.Contains" Visible=@showAssetColumns>
                                        <FilterTemplate>
                                            <RadzenDropDown @bind-Value="@selectedOwner" Style="width:100%" Data=@ownerList Change="@OnSelectedOwnerChange" AllowClear="true" Multiple="true"
                                                            FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                                        </FilterTemplate>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="SerialNumber" Title="シリアル番号" Visible=@showAssetColumns />

                                    <RadzenDataGridColumn TItem="Asset" Property="Ticket.TicketId" Title="チケット番号" Visible=@showTicketColumns>
                                        <Template Context="Asset">
                                            @Asset.Ticket?.TicketId
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="Ticket.TicketDate" Title="チケット作成日" Visible=@showTicketColumns>
                                        <Template Context="Asset">
                                            @Asset.Ticket?.ApplyDate
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="Usage.Fault" Title="故障" Visible=@showUsageColumns>
                                        <Template Context="Asset">
                                            @Asset.Usage?.Fault
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="Usage.Note" Title="備考" Visible=@showUsageColumns>
                                        <Template Context="Asset">
                                            @Asset.Usage?.Note
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="Lease.ReferenceId" Title="レンタル管理番号" Visible=@(showLeaseColumns && selectedCategory == "leased")>
                                        <Template Context="Asset">
                                            @Asset.Lease?.ReferenceId
                                        </Template>
                                    </RadzenDataGridColumn>

                                    <RadzenDataGridColumn TItem="Asset" Property="Lease.EndDate" Title="レンタル終了日" FormatString="yyyy/MM/dd" Visible=@(showLeaseColumns && selectedCategory == "leased")>
                                        <Template Context="Asset">
                                            @Asset.Lease?.EndDate
                                        </Template>
                                    </RadzenDataGridColumn>
                                </Columns>
                            </RadzenDataGrid>
                        </RadzenAccordionItem>
                    </Items>
                </RadzenAccordion>
            </div>
        </div>
    </div>
}

C#コード部分続き

AssetViewer.razor
@code {
    // 読み込んだデータベースのデータ
    private IList<Asset>? assets;

    // データグリッド
    private RadzenDataGrid<Asset> grid;

    // カテゴリ選択 /////////////////////////////////////////////////
    // 選択中のカテゴリ
    public string selectedCategory = "leased";
    // カテゴリのリスト
    private List<string> categoryList = new List<string> { "leased", "purchased", "token", "newtwork" };
    // カテゴリ選択時に呼ばれるメソッド
    private async Task OnChangeCategory()
    {
        selectedRows = null;
        await LoadAssetData();
        await grid.Reload();
    }

    // カラム選択 /////////////////////////////////////////////////
    // Assetカラム
    private bool showAssetColumns = true;
    // Usageカラム
    private bool showUsageColumns = true;
    // Leaseカラム
    private bool showLeaseColumns = false;
    // Ticketカラム
    private bool showTicketColumns = false;

    // 選択中のアイテム
    private IList<Asset>? selectedRows;


    //// フィルタ選択用
    // AssetCode カラム
    private IEnumerable<string>? selectedAssetCode;
    private IEnumerable<string>? assetCodeList;
    void OnSelectedAssetCodeChange()
    {
        if (selectedAssetCode != null && !selectedAssetCode.Any())
            selectedAssetCode = null;
    }

    // State カラム
    private IEnumerable<string>? selectedState;
    private IEnumerable<string?>? stateList;
    void OnSelectedStateChange()
    {
        if (selectedState != null && !selectedState.Any())
            selectedState = null;
    }

    // Owner カラム
    private IEnumerable<string>? selectedOwner;
    private IEnumerable<string?>? ownerList;
    void OnSelectedOwnerChange()
    {
        if (selectedOwner != null && !selectedOwner.Any())
            selectedOwner = null;
    }

    // フィルタリストの設定
    private void SetFilterLists()
    {
        if (assets != null)
        {
            assetCodeList = assets.Select(a => a.AssetCode).Distinct();
            stateList = assets.Select(a => a.State).Distinct();
            ownerList = assets.Select(a => a.Owner).Distinct();
        }
    }

    // データベースのデータ読み込み
    private async Task LoadAssetData()
    {
        switch (selectedCategory)
        {
            case "leased":
                assets = await AssetService.GetLeasedAssets();
                break;
            case "purchased":
                assets = await AssetService.GetPurchasedAssets();
                break;
            case "token":
                break;
            case "newtwork":
                break;
            default:
                assets = await AssetService.GetLeasedAssets();
                break;
        }

        // フィルタリストの設定
        SetFilterLists();
    }

    // 初期化
    protected override async Task OnInitializedAsync()
    {
        await LoadAssetData();
    }


    // 行の読み込み
    private void OnRowRender(RowRenderEventArgs<Asset> args)
    {
        var state = args.Data.State;

        if (state == "Retired")
        {
            args.Attributes.Add("class", "rz-background-color-base-500");
        }
    }

    // ファイルアップロード /////////////////////////////////////////////////
    // 選択されたファイル
    private IBrowserFile? selectedFile;
    // アップロード中かどうか
    private bool isUploading = false;
    // エラーメッセージ用変数
    private string? uploadErrorMessage = "";
    // ファイルが選択されたら呼ばれるメソッド
    private void OnFileSelected(InputFileChangeEventArgs e)
    {
        isUploading = false;
        selectedFile = e.File;
        uploadErrorMessage = "";
    }

    // ファイルアップロードのメソッド
    private async Task UploadFile()
    {
        if (selectedFile == null) return;

        try
        {
            isUploading = true;
            await AssetService.ImportAssetsData(selectedFile);
            var message = new NotificationMessage { Severity = NotificationSeverity.Success, Summary = "Upload", Detail = "Done!", Duration = 5000 };
            NotificationService.Notify(message);
        }
        catch (Exception e)
        {
            var message = new NotificationMessage { Severity = NotificationSeverity.Error, Summary = "Upload", Detail = $"Failed!", Duration = 5000 };
            NotificationService.Notify(message);
            uploadErrorMessage = e.Message;
        }
        finally
        {
            isUploading = false;
            selectedFile = null;
            await LoadAssetData();
            if (grid != null) await grid.Reload();
        }
    }


    // フィルタ後のアイテムのコピー
    private void CopySelectedRows()
    {
        //if (selectedRows == null) return;
        if (selectedRows == null) return;
        var copyData = string.Join("\n", selectedRows.Select(row => $"{string.Join("\t", row.GetType().GetProperties().Select(prop => prop.GetValue(row)))}"));
        JS.InvokeVoidAsync("navigator.clipboard.writeText", copyData);
    }


    // フィルタ後のアイテムのエクスポート
    public async Task DownloadCsv()
    {
        if (selectedRows == null) return;
        var records = selectedRows;

        var configuration = new CsvConfiguration(CultureInfo.InvariantCulture)
        {
            HasHeaderRecord = true,
            HeaderValidated = null,
            MissingFieldFound = null,
            IgnoreBlankLines = false,
        };

        using (var memoryStream = new MemoryStream())
        using (var writer = new StreamWriter(memoryStream))
        using (var csv = new CsvWriter(writer, configuration))
        {
            csv.Context.RegisterClassMap(new AssetMap(selectedCategory));
            csv.WriteRecords(records);
            writer.Flush();
            memoryStream.Position = 0;
            string fileName = $"assets_export.csv";
            using var streamRef = new DotNetStreamReference(memoryStream);
            await JS.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
        }
    }


    // エディットモード関連
    private async Task OpenEditor(int? id)
    {
        try
        {
            await DialogService.OpenAsync<AssetEditor>(
            $"Editting: {id}",
            new Dictionary<string, object>() { { "Id", id }, { "Category", selectedCategory } },
            new DialogOptions() { Width = "560px", Resizable = true, Draggable = true, CloseDialogOnOverlayClick = true });

            var message = new NotificationMessage { Severity = NotificationSeverity.Success, Summary = "Item Edit", Detail = $"Success!", Duration = 5000 };
            NotificationService.Notify(message);
        }
        catch (Exception e)
        {
            var message = new NotificationMessage { Severity = NotificationSeverity.Error, Summary = "Item Edit", Detail = $"Failed!: {e.Message}", Duration = 50000 };
            NotificationService.Notify(message);
        }
        finally
        {
            await LoadAssetData();
            if (grid != null) await grid.Reload();
        }
    }
}

AssetViewerのCSS

ドロップゾーンにファイルをドラッグした時に範囲がわかりやすくなるようにcssファイルを追加します。

AssetViewer.razor.css
::deep .drop-zone {
    padding: 16px;
    border: dashed 4px transparent;
    transition: border linear 0.2s;
}

::deep .drop-zone.hover {
    border: dashed 4px darkgrey;
}

AssetEditor

続いて、データの個別編集、追加用にダイアログ表示させるAssetEditorページを作成していきます。ちなみに、一般的に使われる<DataAnnotationsValidator />の代わりに<ObjectGraphDataAnnotationsValidator />を使うことで、親モデルのValidationだけでなく、リレーションシップでつながっている子モデルのValidationも有効になります。つまり、子モデルの各プロパティに記述した[Required]などのAnnotationが有効になります。

AssetEditor.razor
@page "/asseteditor/{ID:int?}"

@using Microsoft.Extensions.Logging
@using BlazorServer1.Models
@using Radzen
@using Radzen.Blazor
@using BlazorServer1.Services

@inject AssetService AssetService
@inject ILogger<AssetEditor> Logger
@inject HttpClient Http
@inject DialogService dialogService


@if (asset == null || copiedAsset == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <EditForm Model="@asset" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit">
        @*<DataAnnotationsValidator />*@
        <ObjectGraphDataAnnotationsValidator />
        @*<ValidationSummary />*@
        <RadzenSteps NextText="確認画面へ進む" PreviousText="編集画面に戻る">
            <Steps>
                <RadzenStepsItem Text="Edit">
                    <div class="container-fluid">
                        <RadzenFieldset AllowCollapse="true" Collapsed="true" class="mb-2">
                            <HeaderTemplate>
                                <span class="d-inline-flex align-items-center align-middle">
                                    <RadzenIcon Icon="devices" class="me-2" /><b>Product Info</b>
                                </span>
                            </HeaderTemplate>
                            <ChildContent>
                                <div class="row align-items-center mb-2">
                                    <div class="col-md-4">
                                        <RadzenText TextStyle="TextStyle.Subtitle2" Text="機体番号" />
                                    </div>
                                    @if (Id == null)
                                    {
                                        <div class="col">
                                            <RadzenAutoComplete Data="@AssetCodeList" Style="display: block" Name="AssetCode" @bind-value=@asset.AssetCode FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100" />
                                            <ValidationMessage For="() => asset.AssetCode" />
                                        </div>
                                    }
                                    else
                                    {
                                        <div class="col">
                                            <RadzenLabel Style="display: block" Text="@asset.AssetCode" />
                                        </div>
                                    }
                                </div>

                                <div class="row align-items-center mb-2">
                                    <div class="col-md-4">
                                        <RadzenText TextStyle="TextStyle.Subtitle2" Text="ステータス" />
                                    </div>
                                    <div class="col">
                                        <RadzenAutoComplete Data="@StateList" Style="display: block" Name="State" @bind-value=@asset.State FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100" />
                                        <ValidationMessage For="() => asset.State" />
                                    </div>
                                </div>

                                <div class="row align-items-center mb-2">
                                    <div class="col-md-4">
                                        <RadzenText TextStyle="TextStyle.Subtitle2" Text="所有元" />
                                    </div>
                                    <div class="col">
                                        <RadzenAutoComplete Data="@OwnerList" Style="display: block" Name="Owner" @bind-value=@asset.Owner FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100" />
                                        <ValidationMessage For="() => asset.Owner" />
                                    </div>
                                </div>

                                <div class="row align-items-center mb-2">
                                    <div class="col-md-4">
                                        <RadzenText TextStyle="TextStyle.Subtitle2" Text="シリアル番号" />
                                    </div>
                                    <div class="col">
                                        <RadzenAutoComplete Data="@SerialNumberList" Style="display: block" Name="SerialNumber" @bind-value=@asset.SerialNumber FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100" />
                                        <ValidationMessage For="() => asset.SerialNumber" />
                                    </div>
                                </div>
                            </ChildContent>
                        </RadzenFieldset>

                        @if (asset.Ticket != null)
                        {
                            <RadzenFieldset AllowCollapse="true" Collapsed="false" class="mb-2">
                                <HeaderTemplate>
                                    <span class="d-inline-flex align-items-center align-middle">
                                        <RadzenIcon Icon="approval" class="me-2" /><b>Ticket Info</b>
                                    </span>
                                </HeaderTemplate>
                                <ChildContent>
                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                            <RadzenText TextStyle="TextStyle.Subtitle2" Text="チケット番号" />
                                        </div>
                                        <div class="col">
                                            <RadzenAutoComplete Data="@TicketIdList" Style="display: block" Name="TicketId" @bind-Value=@asset.Ticket.TicketId FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100"  />
                                            <ValidationMessage For="() => asset.Ticket.TicketId" />
                                        </div>
                                    </div>

                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                            <RadzenText TextStyle="TextStyle.Subtitle2" Text="チケット作成日" />
                                        </div>
                                        <div class="col">
                                            <RadzenDatePicker Style="display: block" Name="TicketDate" TValue="DateTime?" @bind-Value=@asset.Ticket.ApplyDate class="w-100"  />
                                            <ValidationMessage For="() => asset.Ticket.ApplyDate" />
                                        </div>
                                    </div>
                                </ChildContent>
                            </RadzenFieldset>
                        }

                        @if (asset.Usage != null)
                        {
                            <RadzenFieldset AllowCollapse="true" Collapsed="false" class="mb-2">
                                <HeaderTemplate>
                                    <span class="d-inline-flex align-items-center align-middle">
                                        <RadzenIcon Icon="support_agent" class="me-2" /><b>Usage Info</b>
                                    </span>
                                </HeaderTemplate>
                                <ChildContent>
                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="故障" />
                                            </div>
                                        <div class="col">
                                            <RadzenCheckBox Style="display: block" Name="Fault" @bind-Value=@asset.Usage.Fault  />
                                            <ValidationMessage For="() => asset.Usage.Fault" />
                                        </div>
                                    </div>

                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="備考" />
                                            </div>
                                        <div class="col">
                                            <RadzenAutoComplete Data="@NoteList" Style="display: block" Name="Note" @bind-Value=@asset.Usage.Note FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100"  />
                                            <ValidationMessage For="() => asset.Usage.Note" />
                                        </div>
                                    </div>
                                </ChildContent>
                            </RadzenFieldset>
                        }

                        @if (asset.Lease != null)
                        {
                            <RadzenFieldset AllowCollapse="true" Collapsed="true" class="mb-2">
                                <HeaderTemplate>
                                    <span class="d-inline-flex align-items-center align-middle">
                                        <RadzenIcon Icon="handshake" class="me-2" />Lease Info
                                    </span>
                                </HeaderTemplate>
                                <ChildContent>
                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="レンタル管理番号" />
                                            </div>
                                        <div class="col">
                                            <RadzenAutoComplete Data="@ReferenceIdList" Style="display: block" Name="ReferenceId" @bind-value=@asset.Lease.ReferenceId FilterDelay="100" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" class="w-100"  />
                                            <ValidationMessage For="() => asset.Lease.ReferenceId" />
                                        </div>
                                    </div>

                                    <div class="row align-items-center mb-2">
                                        <div class="col-md-4">
                                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="レンタル終了日" />
                                            </div>
                                        <div class="col">
                                            <RadzenDatePicker Style="display: block" Name="EndDate" TValue="DateTime?" @bind-value=@asset.Lease.EndDate class="w-100"  />
                                            <ValidationMessage For="() => asset.Lease.EndDate" />
                                        </div>
                                    </div>
                                </ChildContent>
                            </RadzenFieldset>
                        }
                    </div>
                </RadzenStepsItem>

                @*Confirm*@
                <RadzenStepsItem Text="Confirm">
                    <div class="container-fluid">
                        <div class="row align-items-center mb-2">
                            <div class="col-md-3">
                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="機体番号" />
                            </div>
                            <div class="col-md-4">
                                <RadzenText Text="@copiedAsset.AssetCode" TextAlign="TextAlign.Right" />
                            </div>
                            @if (asset.AssetCode != copiedAsset.AssetCode)
                            {
                                <div class="col-auto mx-1">
                                    <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                </div>
                                <div class="col">
                                    <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.AssetCode" class="rz-color-secondary" />
                                </div>
                            }
                        </div>

                        <div class="row align-items-center mb-2">
                            <div class="col-md-3">
                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="ステータス" />
                            </div>
                            <div class="col-md-4">
                                <RadzenText Style="display: block" Text="@copiedAsset.State" TextAlign="TextAlign.Right" />
                            </div>
                            @if (asset.State != copiedAsset.State)
                            {
                                <div class="col-auto mx-1">
                                    <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                </div>
                                <div class="col">
                                    <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.State" class="rz-color-secondary" />
                                </div>
                            }
                        </div>

                        <div class="row align-items-center mb-2">
                            <div class="col-md-3">
                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="所有元" />
                            </div>
                            <div class="col-md-4">
                                <RadzenText Style="display: block" Text="@copiedAsset.Owner" TextAlign="TextAlign.Right" />
                            </div>
                            @if (asset.Owner != copiedAsset.Owner)
                            {
                                <div class="col-auto mx-1">
                                    <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                </div>
                                <div class="col">
                                    <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Owner" class="rz-color-secondary" />
                                </div>
                            }
                        </div>

                        <div class="row align-items-center mb-2">
                            <div class="col-md-3">
                                <RadzenText TextStyle="TextStyle.Subtitle2" Text="シリアル番号" />
                            </div>
                            <div class="col-md-4">
                                <RadzenText Style="display: block" Text=@copiedAsset.SerialNumber TextAlign="TextAlign.Right" />
                            </div>
                            @if (asset.SerialNumber != copiedAsset.SerialNumber)
                            {
                                <div class="col-auto mx-1">
                                    <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                </div>
                                <div class="col-md-3">
                                    <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.SerialNumber" class="rz-color-secondary" />
                                </div>
                            }
                        </div>

                        @if (asset.Lease != null && copiedAsset.Lease != null)
                        {

                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="レンタル管理番号" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Lease.ReferenceId TextAlign="TextAlign.Right" />
                                </div>
                                @if (asset.Lease.ReferenceId != copiedAsset.Lease.ReferenceId)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Lease.ReferenceId" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>

                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="レンタル終了日" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Lease.EndDate?.ToString("d") TextAlign="TextAlign.Right" Format="yyyy/MM/dd" />
                                </div>
                                @if (asset.Lease.EndDate != copiedAsset.Lease.EndDate)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Lease.EndDate?.ToString("d")" Format="yyyy/MM/dd" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>
                        }

                        @if (asset.Ticket != null && copiedAsset.Ticket != null)
                        {
                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="チケット番号" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Ticket.TicketId TextAlign="TextAlign.Right" />
                                </div>
                                @if (asset.Ticket.TicketId != copiedAsset.Ticket.TicketId)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Ticket.TicketId" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>

                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="チケット作成日" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Ticket.ApplyDate?.ToString("d") TextAlign="TextAlign.Right" />
                                </div>
                                @if (asset.Ticket.ApplyDate != copiedAsset.Ticket.ApplyDate)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Ticket.ApplyDate?.ToString("d")" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>
                        }

                        @if (asset.Usage != null && copiedAsset.Usage != null)
                        {
                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="故障" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Usage.Fault.ToString() TextAlign="TextAlign.Right" />
                                </div>
                                @if (asset.Usage?.Fault != copiedAsset.Usage.Fault)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Usage?.Fault.ToString()" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>

                            <div class="row align-items-center mb-2">
                                <div class="col-md-3">
                                    <RadzenText TextStyle="TextStyle.Subtitle2" Text="備考" />
                                </div>
                                <div class="col-md-4">
                                    <RadzenText Style="display: block" Text=@copiedAsset.Usage.Note TextAlign="TextAlign.Right" />
                                </div>
                                @if (asset.Usage?.Note != copiedAsset.Usage.Note)
                                {
                                    <div class="col-auto mx-1">
                                        <RadzenIcon Icon="arrow_forward" IconStyle="IconStyle.Secondary" />
                                    </div>
                                    <div class="col">
                                        <RadzenLabel Style="display: block; font-weight: bold;" Text="@asset.Usage?.Note" class="rz-color-secondary" />
                                    </div>
                                }
                            </div>
                        }

                        <div class="row my-4">
                            <div class="col"></div>
                            <div class="col-md-4">
                                <RadzenButton ButtonType="ButtonType.Submit" ButtonStyle="ButtonStyle.Secondary" Text="Submit" />
                            </div>
                        </div>
                    </div>
                </RadzenStepsItem>
            </Steps>
        </RadzenSteps>
    </EditForm>
}@code {
    [Parameter]
    public int? Id { get; set; }
    [Parameter]
    public string? category { get; set; }

    private Asset? asset;
    private Asset? copiedAsset;

    // Data lists for RadzenAutoComplete
    private IEnumerable<string>? AssetCodeList;
    private IEnumerable<string?>? OwnerList;
    private IEnumerable<string?>? SerialNumberList;
    private IEnumerable<string?>? ReferenceIdList;
    private IEnumerable<string?>? TicketIdList;
    private IEnumerable<string?>? StateList;
    private IEnumerable<string?>? NoteList;

    private async Task GetDataLists()
    {
        var assets = await AssetService.GetAssets();
        AssetCodeList = assets.Select(a => a.AssetCode).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        StateList = assets.Select(a => a.State).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        OwnerList = assets.Select(a => a.Owner).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        SerialNumberList = assets.Select(a => a.SerialNumber).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        ReferenceIdList = assets.Select(a => a.Lease?.ReferenceId).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        TicketIdList = assets.Select(a => a.Ticket?.TicketId).Where(a => !string.IsNullOrEmpty(a)).Distinct();
        NoteList = assets.Select(a => a.Usage?.Note).Where(a => !string.IsNullOrEmpty(a)).Distinct();
    }
    protected override async Task OnInitializedAsync()
    {
        Logger.LogInformation("OnInitializedAsync called");

        if (Id == null)
        {
            Logger.LogInformation("Id parameter is null in OnInitializedAsync");
            switch (category)
            {
                case "leased":
                    asset = new Asset
                    {
                        Ticket = new Ticket(),
                        Usage = new Usage { Fault = false },
                        Lease = new Lease()
                    };
                    break;
                case "purchased":
                    asset = new Asset
                    {
                        Ticket = new Ticket(),
                        Usage = new Usage { Fault = false }
                    };
                    break;
                default:
                    dialogService.Close();
                    break;
            }
        }
        else
        {
            Logger.LogInformation($"Id parameter is {Id} in OnInitializedAsync");
            switch (category)
            {
                case "leased":
                    asset = await AssetService.GetLeasedAsset(Id);
                    break;
                case "purchased":
                    asset = await AssetService.GetPurchasedAsset(Id);
                    break;
                default:
                    dialogService.Close();
                    break;
            }
        }
        copiedAsset = asset?.Clone();
        await GetDataLists();
    }


    private async Task HandleValidSubmit()
    {
        Logger.LogInformation("Valid submit");

        if (asset != null && asset.Usage != null)
        {
            if (Id == null)
            {
                await AssetService.SaveAddingAsset(asset);
            }
            else
            {
                await AssetService.SaveUpdatingAsset(asset);
            }
        }
        dialogService.Close();
    }

    private void HandleInvalidSubmit()
    {
        Logger.LogInformation("Invalid submit");
    }
}

HistoryViewer

最後にデータの更新履歴を確認するためのHistoryViewerページを作成します。

HistoryViewer.razor
@page "/historyviewer"

@using System.Linq
@using CsvHelper
@using CsvHelper.Configuration
@using System.Globalization
@using System.IO
@using System.Net.Http
@using System.Threading.Tasks

@using Radzen
@using Radzen.Blazor
@using BlazorServer1.Models
@using BlazorServer1.Services

@inject AssetService AssetService
@inject NavigationManager NavigationManager
@inject DialogService DialogService
@inject NotificationService NotificationService
@inject IJSRuntime JS


<RadzenText TextStyle="TextStyle.DisplayH4">
    <RadzenIcon Icon="list" />
    Update History
</RadzenText>


@if (updates == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <div class="container-fluid">
        <div class="row">
            <div class="col pt-2">

                <RadzenDataGrid AllowColumnResize="true" ColumnWidth="150px" AllowVirtualization="true" AllowSorting="true" class="height:400px;"
                                AllowGrouping="true" AllGroupsExpanded="false" Render="@OnRender"
                                AllowFiltering="true" FilterMode="FilterMode.Simple" LogicalFilterOperator="LogicalFilterOperator.And" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
                                @ref="grid" Data="@updates" TItem="UpdateHistory">
                    <GroupHeaderTemplate>
                        @context.GroupDescriptor.GetTitle(): @(context.Data.Key ?? "") / 更新者: @(context.Data.Items.Cast<UpdateHistory>().FirstOrDefault()?.Updater)
                    </GroupHeaderTemplate>
                    <Columns>

                        @*データグリッドカラム*@
                        <RadzenDataGridColumn TItem="UpdateHistory" Width="48px" Sortable="false" Filterable="false" Frozen="true">
                            <HeaderTemplate>
                                <RadzenCheckBox TriState="false" TValue="bool" Value="@(updates.Any(i => selectedRows != null && selectedRows.Contains(i)))" Change="@(args => selectedRows = args ? updates.ToList() : null)" />
                            </HeaderTemplate>
                            <Template Context="data">
                                <RadzenCheckBox TriState="false" Value="@(selectedRows != null && selectedRows.Contains(data))" TValue="bool" Change=@(args => grid.SelectRow(data)) />
                            </Template>
                        </RadzenDataGridColumn>

                        <RadzenDataGridColumn TItem="UpdateHistory" Type="typeof(IEnumerable<string>)" Property="AssetCode" Title="機体番号" Frozen="true" FilterValue="@selectedAssetCode" FilterOperator="FilterOperator.Contains">
                            <FilterTemplate>
                                <RadzenDropDown @bind-Value="@selectedAssetCode" Style="width:100%" Data=@assetCodeList Change="@OnSelectedAssetCodeChange" AllowClear="true" Multiple="true"
                                                FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                            </FilterTemplate>
                        </RadzenDataGridColumn>

                        <RadzenDataGridColumn TItem="UpdateHistory" Property="UpdateTime" Title="更新日時" Frozen="true" />

                        <RadzenDataGridColumn TItem="UpdateHistory" Type="typeof(IEnumerable<string>)" Property="Updater" Title="更新者" Frozen="true" FilterValue="@selectedUpdater" FilterOperator="FilterOperator.Contains">
                            <FilterTemplate>
                                <RadzenDropDown @bind-Value="@selectedUpdater" Style="width:100%" Data=@updaterList Change="@OnSelectedUpdaterChange" AllowClear="true" Multiple="true"
                                                FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                            </FilterTemplate>
                        </RadzenDataGridColumn>

                        <RadzenDataGridColumn TItem="UpdateHistory" Type="typeof(IEnumerable<string>)" Property="Action" Title="アクション" FilterValue="@selectedAction" FilterOperator="FilterOperator.Contains">
                            <FilterTemplate>
                                <RadzenDropDown @bind-Value="@selectedAction" Style="width:100%" Data=@actionList Change="@OnSelectedActionChange" AllowClear="true" Multiple="true"
                                                FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive" FilterOperator="StringFilterOperator.Contains" AllowFiltering="true" />
                            </FilterTemplate>
                        </RadzenDataGridColumn>

                        <RadzenDataGridColumn TItem="UpdateHistory" Property="ChangedColumn" Title="変更した列" />
                        <RadzenDataGridColumn TItem="UpdateHistory" Property="OldValue" Title="変更前" />
                        <RadzenDataGridColumn TItem="UpdateHistory" Property="NewValue" Title="変更後" />

                    </Columns>
                </RadzenDataGrid>
            </div>
        </div>
    </div>
}

C#コード部分続き

HistoryViewer.razor
@code {
    // 読み込んだデータベースのデータ
    private IList<UpdateHistory>? updates;

    // データグリッド
    private RadzenDataGrid<UpdateHistory>? grid;

    // ** アイテムの選択 **
    // 選択中のアイテム
    private IList<UpdateHistory>? selectedRows;

    // データベースのデータ読み込みメソッド
    private async Task LoadAssetData()
    {
        updates = await AssetService.GetUpdateHistory();
    }

    // 初期化
    protected override async Task OnInitializedAsync()
    {
        await LoadAssetData();
        if (updates != null)
        {
            assetCodeList = updates.Select(a => a.AssetCode).Distinct().ToList();
            actionList = updates.Select(a => a.Action).Distinct().ToList();
            updaterList = updates.Select(a => a.Updater).Distinct().ToList();
            updateTimeList = updates.Select(a => a.UpdateTime).Distinct().ToList();
        }
    }

    // DataGrid読み込み時に呼ばれるメソッド(Groupingの初期設定)
    private void OnRender(DataGridRenderEventArgs<UpdateHistory> args)
    {
        if (args.FirstRender)
        {
            args.Grid.Groups.Add(new GroupDescriptor() { Title = "更新日時", Property = "UpdateTime" });
            StateHasChanged();
        }
    }

    //// フィルタ選択用
    // AssetCode カラム
    private IEnumerable<string>? selectedAssetCode;
    private List<string?>? assetCodeList;
    void OnSelectedAssetCodeChange(object value)
    {
        if (selectedAssetCode != null && !selectedAssetCode.Any())
            selectedAssetCode = null;
    }

    // Action カラム
    private IEnumerable<string>? selectedAction;
    private List<string?>? actionList;
    void OnSelectedActionChange(object value)
    {
        if (selectedAction != null && !selectedAction.Any())
            selectedAction = null;
    }

    // Updater カラム
    private IEnumerable<string>? selectedUpdater;
    private List<string?>? updaterList;
    void OnSelectedUpdaterChange(object value)
    {
        if (selectedUpdater != null && !selectedUpdater.Any())
            selectedUpdater = null;
    }

    // UpdateDate カラム
    private IEnumerable<DateTime>? selectedUpdateTime;
    private List<DateTime?>? updateTimeList;
    void OnSelectedUpdateTimeChange(object value)
    {
        if (updateTimeList != null && !updateTimeList.Any())
            updateTimeList = null;
    }
}

以上でアプリのコードは完成です。

もしIIS のサブフォルダとしてアプリをデプロイする場合などはbase urlの変更が必要です。
'MainLayout.razor'ファイルや'NavMenu.razor'ファイルなどにindes.razorへのリンクを用意している場合は"/"だとサブフォルダではなくメインフォルダのURLへ飛んでしまうので、以下のようにします。

@inject NavigationManager NavigationManager

<a href="@NavigationManager.BaseUri">Go to Index Page</a>
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3