Update document handling and styling improvements

- Changed `app.css` path in `App.razor`.
- Refactored `Documents.razor` to improve form handling:
  - Removed `VectorSearchService` injection; added `IServiceProvider` and `IJSRuntime`.
  - Updated header from `<h2>` to `<h4>`.
  - Introduced `uploadDocumentRequest` for form state management.
  - Modified document ID input for optional GUID with validation.
  - Disabled upload button when no file is selected.
  - Enhanced document loading logic with scoped service provider.
  - Updated deletion logic to handle multiple document IDs.

- Added method in `DocumentService.cs` for bulk document deletion.
- Restructured `app.css` for improved styling and new validation/error message styles.
This commit is contained in:
Marco Minerva
2025-02-17 12:32:12 +01:00
parent f9a2bf0bf9
commit be3f0dbf09
5 changed files with 73 additions and 29 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
<link href="_content/Blazor.Bootstrap/blazor.bootstrap.css" rel="stylesheet" /> <link href="_content/Blazor.Bootstrap/blazor.bootstrap.css" rel="stylesheet" />
<script src="https://kit.fontawesome.com/f7a7b34f96.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/f7a7b34f96.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="@Assets["app.css"]" /> <link rel="stylesheet" href="@Assets["css/app.css"]" />
<link rel="stylesheet" href="@Assets["SqlDatabaseVectorSearch.styles.css"]" /> <link rel="stylesheet" href="@Assets["SqlDatabaseVectorSearch.styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
@@ -1,8 +1,7 @@
@page "/documents" @page "/documents"
@using MimeMapping @using MimeMapping
@inject VectorSearchService vectorSearchService @inject IServiceProvider ServiceProvider
@inject DocumentService documentService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
<Toasts class="p-3" Messages="messages" Placement="ToastsPlacement.TopRight" /> <Toasts class="p-3" Messages="messages" Placement="ToastsPlacement.TopRight" />
@@ -10,12 +9,14 @@
<PageTitle>Documents</PageTitle> <PageTitle>Documents</PageTitle>
<h2 class="mb-4"> <h4 class="mb-4">
<Icon Name="IconName.Upload" class="me-2" /> <Icon Name="IconName.Upload" class="me-2" />
Upload new document Upload new document
</h2> </h4>
<EditForm EditContext="@editContext" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
<EditForm Model="@this" OnValidSubmit="HandleValidSubmit">
<div class="row"> <div class="row">
<div class="col-md-5 col-sm-4 col-5"> <div class="col-md-5 col-sm-4 col-5">
<div class="input-group"> <div class="input-group">
@@ -35,23 +36,30 @@
</Tooltip> </Tooltip>
Document ID Document ID
</span> </span>
<TextInput Placeholder="Enter a valid GUID" @bind-Value="documentId" /> <TextInput Placeholder="Enter a valid GUID or leave empty for auto-generation" @bind-Value="@uploadDocumentRequest.DocumentId" />
</div> </div>
<ValidationMessage For="@(() => uploadDocumentRequest.DocumentId)" />
</div> </div>
<div class="col-md-2 col-sm-3 col-2"> <div class="col-md-2 col-sm-3 col-2">
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<Button @ref="uploadButton" Type="ButtonType.Submit" Color="ButtonColor.Primary" To="#"><Icon Name="IconName.Upload" /><span class="d-none d-lg-inline ps-3">Upload</span></Button> <Button @ref="uploadButton" Type="ButtonType.Submit" Color="ButtonColor.Primary" To="#" Disabled="@(uploadDocumentRequest.File is null)"><Icon Name="IconName.Upload" /><span class="d-none d-lg-inline ps-3">Upload</span></Button>
</div> </div>
</div> </div>
</div> </div>
</EditForm> </EditForm>
@if (documents.Count > 0) @if (documents.Count == 0)
{ {
<h2 class="mt-4"> <div class="text-center">
<Spinner Type="SpinnerType.Dots" Class="me-3 mt-4" Color="SpinnerColor.Primary" />
</div>
}
else
{
<h4 class="mt-4">
<Icon Name="IconName.Files" class="me-2" /> <Icon Name="IconName.Files" class="me-2" />
Available documents Available documents
</h2> </h4>
<table class="table table-bordered table-striped table-hover"> <table class="table table-bordered table-striped table-hover">
<thead> <thead>
@@ -100,10 +108,14 @@
private IList<SelectableDocument> documents = []; private IList<SelectableDocument> documents = [];
private List<ToastMessage> messages = []; private List<ToastMessage> messages = [];
[SupplyParameterFromForm] private UploadDocumentRequest uploadDocumentRequest = new();
public IBrowserFile? File { get; set; } private EditContext? editContext;
public string? documentId { get; set; } protected override void OnInitialized()
{
editContext = new EditContext(uploadDocumentRequest);
base.OnInitialized();
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -112,11 +124,14 @@
return; return;
} }
await LoadDocumentsAsync(); await Task.Delay(2000);
await using var scope = ServiceProvider.CreateAsyncScope();
await LoadDocumentsAsync(scope.ServiceProvider);
} }
private async Task LoadDocumentsAsync() private async Task LoadDocumentsAsync(IServiceProvider services)
{ {
var documentService = services.GetRequiredService<DocumentService>();
var dbDocuments = await documentService.GetAsync(); var dbDocuments = await documentService.GetAsync();
documents.Clear(); documents.Clear();
@@ -133,12 +148,12 @@
private void HandleFileSelected(InputFileChangeEventArgs e) private void HandleFileSelected(InputFileChangeEventArgs e)
{ {
File = e.File; uploadDocumentRequest.File = e.File;
} }
private async Task HandleValidSubmit() private async Task HandleValidSubmit()
{ {
if (File is null) if (uploadDocumentRequest.File is null)
{ {
return; return;
} }
@@ -147,15 +162,19 @@
try try
{ {
await using var inputStream = File.OpenReadStream(20 * 1024 * 1024); // 20 MB var fileName = uploadDocumentRequest.File.Name;
await using var inputStream = uploadDocumentRequest.File.OpenReadStream(20 * 1024 * 1024); // 20 MB
await using var stream = await inputStream.GetMemoryStreamAsync(); await using var stream = await inputStream.GetMemoryStreamAsync();
await vectorSearchService.ImportAsync(stream, File.Name, MimeUtility.GetMimeMapping(File.Name), Guid.Parse(documentId)); await using var scope = ServiceProvider.CreateAsyncScope();
var vectorSearchService = scope.ServiceProvider.GetRequiredService<VectorSearchService>();
var documentId = string.IsNullOrWhiteSpace(uploadDocumentRequest.DocumentId) ? null : (Guid?)Guid.Parse(uploadDocumentRequest.DocumentId);
await vectorSearchService.ImportAsync(stream, fileName, MimeUtility.GetMimeMapping(fileName), documentId);
CreateToastMessage(ToastType.Success, "Upload document", $"The document {File.Name} has been successfully uploaded and indexed.", await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow)); CreateToastMessage(ToastType.Success, "Upload document", $"The document {fileName} has been successfully uploaded and indexed.", await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow));
await LoadDocumentsAsync(); await LoadDocumentsAsync(scope.ServiceProvider);
} }
finally finally
{ {
@@ -165,7 +184,7 @@
private async Task DeleteSelectedDocuments() private async Task DeleteSelectedDocuments()
{ {
var selectedDocuments = documents?.Where(d => d.IsSelected) ?? []; var selectedDocumentIds = documents?.Where(d => d.IsSelected).Select(d => d.Id) ?? [];
var confirmation = await dialog.ShowAsync( var confirmation = await dialog.ShowAsync(
title: "Delete the selected document?", title: "Delete the selected document?",
@@ -181,12 +200,12 @@
{ {
deleteButton.ShowLoading(); deleteButton.ShowLoading();
foreach (var document in selectedDocuments) await using var scope = ServiceProvider.CreateAsyncScope();
{ var documentService = scope.ServiceProvider.GetRequiredService<DocumentService>();
await documentService.DeleteAsync(document.Id);
}
await LoadDocumentsAsync(); await documentService.DeleteAsync(selectedDocumentIds);
await LoadDocumentsAsync(scope.ServiceProvider);
CreateToastMessage(ToastType.Info, "Delete documents", "The selected documents have been successfully deleted.", await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow)); CreateToastMessage(ToastType.Info, "Delete documents", "The selected documents have been successfully deleted.", await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow));
} }
finally finally
@@ -222,4 +241,13 @@
public string LocalCreationDateString { get; set; } = string.Empty; public string LocalCreationDateString { get; set; } = string.Empty;
} }
public class UploadDocumentRequest
{
[SupplyParameterFromForm]
public IBrowserFile? File { get; set; }
[RegularExpression(@"^(\{|\()?[0-9a-fA-F]{8}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{12}(\}|\))?$", ErrorMessage = "Invalid GUID format.")]
public string? DocumentId { get; set; }
}
} }
@@ -1,4 +1,5 @@
@using System.Net.Http @using System.ComponentModel.DataAnnotations
@using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@@ -36,4 +36,7 @@ public class DocumentService(ApplicationDbContext dbContext)
public Task DeleteAsync(Guid documentId, CancellationToken cancellationToken = default) public Task DeleteAsync(Guid documentId, CancellationToken cancellationToken = default)
=> dbContext.Documents.Where(d => d.Id == documentId).ExecuteDeleteAsync(cancellationToken); => dbContext.Documents.Where(d => d.Id == documentId).ExecuteDeleteAsync(cancellationToken);
public Task DeleteAsync(IEnumerable<Guid> documentIds, CancellationToken cancellationToken = default)
=> dbContext.Documents.Where(d => documentIds.Contains(d.Id)).ExecuteDeleteAsync(cancellationToken);
} }
@@ -29,6 +29,18 @@ h1:focus {
color: var(--bb-sidebar2-nav-item-text-active-color) !important; color: var(--bb-sidebar2-nav-item-text-active-color) !important;
} }
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
.blazor-error-boundary { .blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem; padding: 1rem 1rem 1rem 3.7rem;