Fixed bug where deletes of artifact groupings would not cascade
This commit is contained in:
@@ -1,20 +1,52 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.Extensions.Options
|
||||
@using OpenArchival.Blazor.AdminPages
|
||||
@using OpenArchival.DataAccess
|
||||
@using OpenArchival.Blazor.CustomComponents
|
||||
@using MudExRichTextEditor
|
||||
@using MudBlazor
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Components.Forms;
|
||||
@using OpenArchival.Blazor.Config
|
||||
@using OpenArchival.Blazor.AdminPages.Shared;
|
||||
|
||||
@page "/admin/blogedit"
|
||||
@page "/admin/blogedit/{Id}"
|
||||
@layout AdminPages.AdminControlPanelLayout;
|
||||
@page "/admin/blogedit/{Id:int}"
|
||||
@layout AdminControlPanelLayout;
|
||||
|
||||
<MudText Typo="Typo.h6">Create a Blog Post</MudText>
|
||||
<MudDivider></MudDivider>
|
||||
<MudTextField Placeholder="Title" T="string" @bind-Value=@BlogPostModel.Title></MudTextField>
|
||||
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
AppendMultipleFiles=false
|
||||
Accept=".jpg,.png,.webp"
|
||||
MaximumFileCount="1"
|
||||
@bind-Files=_mainPhotoFile
|
||||
Class="mt-4">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary">
|
||||
Upload Main Photo
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
<SelectedTemplate>
|
||||
@if (context != null)
|
||||
{
|
||||
<MudText>@context.Name</MudText>
|
||||
}
|
||||
else if (BlogPostModel.MainPhoto != null)
|
||||
{
|
||||
<MudText>@BlogPostModel.MainPhoto.OriginalName</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>No Main Photo Selected</MudText>
|
||||
}
|
||||
</SelectedTemplate>
|
||||
</MudFileUpload>
|
||||
|
||||
<MudExRichTextEdit @ref="@_contentEditor"
|
||||
ReadOnly="false"
|
||||
Height="444"
|
||||
@@ -25,21 +57,28 @@
|
||||
@((MarkupString)BlogPostModel.Content)
|
||||
</MudExRichTextEdit>
|
||||
|
||||
<MudText Typo="Typo.h6">Add Tags</MudText>
|
||||
<MudDivider />
|
||||
<MudText Typo="Typo.caption" Color="Color.Primary">Press enter to add.</MudText>
|
||||
<ChipContainer T="BlogPostTag" @ref="@_tagsChipContainer" @bind-Items="BlogPostModel.Tags">
|
||||
<InputContent>
|
||||
<MudAutocomplete T="string"
|
||||
Placeholder="Add Tags..."
|
||||
@bind-Text=_tagsInputValue
|
||||
OnKeyDown="HandleTagsEnter"
|
||||
>
|
||||
</MudAutocomplete>
|
||||
</InputContent>
|
||||
</ChipContainer>
|
||||
<InputContent>
|
||||
<MudAutocomplete T="string"
|
||||
Placeholder="Add Tags..."
|
||||
@bind-Text=_tagsInputValue
|
||||
OnKeyDown="HandleTagsEnter">
|
||||
</MudAutocomplete>
|
||||
</InputContent>
|
||||
</ChipContainer>
|
||||
|
||||
<MudText Typo="Typo.h6" Class="mt-4">Link Artifact Groupings</MudText>
|
||||
<MudDivider />
|
||||
<MudText Typo="Typo.caption" Color="Color.Primary">Select Artifact Groupings to Associate with this post.</MudText>
|
||||
<ArchiveGroupingsTable @ref=_artifactGroupingTable></ArchiveGroupingsTable>
|
||||
<MudGrid Justify="Justify.FlexEnd" Class="mr-4 pt-8">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Publish" Color=Color.Primary Variant=Variant.Filled OnClick="OnPublishBlogPost">Publish</MudButton>
|
||||
</MudGrid>
|
||||
|
||||
@inject IOptions<FileUploadOptions> FileOptions;
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
|
||||
@inject ISnackbar Snackbar;
|
||||
@inject NavigationManager NavigationManager;
|
||||
@@ -57,13 +96,23 @@
|
||||
private string _tagsInputValue = "";
|
||||
private ChipContainer<BlogPostTag> _tagsChipContainer = default!;
|
||||
|
||||
private ArchiveGroupingsTable _artifactGroupingTable = default!;
|
||||
|
||||
private IBrowserFile? _mainPhotoFile;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Id != 0)
|
||||
{
|
||||
using var context = ContextFactory.CreateDbContext();
|
||||
|
||||
BlogPost? existingPost = context.BlogPosts.Include(p => p.Tags).Where(p => p.Id == Id).FirstOrDefault();
|
||||
BlogPost? existingPost = context.BlogPosts
|
||||
.Include(p => p.Tags)
|
||||
.Include(p=>p.ArtifactGroupings)
|
||||
.ThenInclude(g=>g.Category)
|
||||
.Include(p=>p.MainPhoto)
|
||||
.Where(p => p.Id == Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existingPost is null)
|
||||
{
|
||||
@@ -76,6 +125,28 @@
|
||||
base.OnParametersSet();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// We only want to run this logic ONCE, on the very first render.
|
||||
if (firstRender)
|
||||
{
|
||||
// Check if we are in "edit" mode (Id != 0) and the loaded post
|
||||
// actually has any groupings to select.
|
||||
if (Id != 0 && BlogPostModel.ArtifactGroupings.Any())
|
||||
{
|
||||
// Now it is safe to call the method because _artifactGroupingTable
|
||||
// has been rendered and is no longer null.
|
||||
await _artifactGroupingTable.SetSelectedItemsAsync(BlogPostModel.ArtifactGroupings);
|
||||
|
||||
// We call StateHasChanged() to ensure the UI updates
|
||||
// with the selection state we just pushed to the child.
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task DeDuplicatePostTags(BlogPost post, ApplicationDbContext context)
|
||||
{
|
||||
List<BlogPostTag> finalTags = [];
|
||||
@@ -85,7 +156,7 @@
|
||||
if (existing is not null)
|
||||
{
|
||||
finalTags.Add(existing);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.BlogPostTags.Add(tag);
|
||||
@@ -116,53 +187,110 @@
|
||||
|
||||
private async Task OnPublishBlogPost(Microsoft.AspNetCore.Components.Web.MouseEventArgs args)
|
||||
{
|
||||
if (string.IsNullOrEmpty(BlogPostModel.Content))
|
||||
{
|
||||
Snackbar.Add("Content is required in the blog post, write something.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(BlogPostModel.Title))
|
||||
{
|
||||
Snackbar.Add("A post title is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
// ... (content and title checks) ...
|
||||
|
||||
await using var context = await ContextFactory.CreateDbContextAsync();
|
||||
BlogPostModel.ModifiedTime = DateTime.UtcNow;
|
||||
|
||||
// Blog post does not exist in database
|
||||
if (BlogPostModel.Id == 0)
|
||||
bool isCreatingNewPost = BlogPostModel.Id == 0;
|
||||
FilePathListing? newPhoto = null; // Will hold the new photo entity, if one is uploaded
|
||||
FilePathListing? oldPhotoToDelete = null; // Will hold the old photo entity for deletion
|
||||
|
||||
// --- 1. Handle New Photo Upload ---
|
||||
if (_mainPhotoFile is not null)
|
||||
{
|
||||
// A new file was uploaded.
|
||||
try
|
||||
{
|
||||
var diskFileName = $"{Guid.NewGuid()}{Path.GetExtension(_mainPhotoFile.Name)}";
|
||||
var destinationPath = Path.Combine(FileOptions.Value.UploadFolderPath, diskFileName);
|
||||
|
||||
await using var browserUploadStream = _mainPhotoFile.OpenReadStream(maxAllowedSize: FileOptions.Value.MaxUploadSizeBytes);
|
||||
await using var outFileStream = new FileStream(destinationPath, FileMode.Create);
|
||||
await browserUploadStream.CopyToAsync(outFileStream);
|
||||
|
||||
// Create the new entity
|
||||
newPhoto = new FilePathListing() { Path = destinationPath, OriginalName = Path.GetFileName(_mainPhotoFile.Name) };
|
||||
|
||||
// Add it to the context so it's tracked
|
||||
context.ArtifactFilePaths.Add(newPhoto);
|
||||
|
||||
// We update the model's reference just in case
|
||||
BlogPostModel.MainPhoto = newPhoto;
|
||||
|
||||
Snackbar.Add($"Uploaded {_mainPhotoFile.Name}", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error uploading file {_mainPhotoFile.Name}: {ex.Message}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (isCreatingNewPost)
|
||||
{
|
||||
// Case: Creating a new post, but no file was provided. This is an error.
|
||||
Snackbar.Add("A main photo is required", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 2. Database Save Logic ---
|
||||
BlogPostModel.ModifiedTime = DateTime.UtcNow;
|
||||
BlogPostModel.ArtifactGroupings = await _artifactGroupingTable.SelectedItems();
|
||||
|
||||
foreach (ArtifactGrouping grouping in BlogPostModel.ArtifactGroupings)
|
||||
{
|
||||
// This tells EF "this object already exists in the database."
|
||||
// It will attach it in an "Unchanged" state without
|
||||
// causing conflicts with its related entities.
|
||||
context.Entry(grouping).State = EntityState.Unchanged;
|
||||
}
|
||||
|
||||
if (isCreatingNewPost)
|
||||
{
|
||||
BlogPostModel.CreationTime = DateTime.UtcNow;
|
||||
|
||||
// Loop through the tags and de-duplicate any of them
|
||||
await DeDuplicatePostTags(BlogPostModel, context);
|
||||
|
||||
BuildBlogSearchIndex(BlogPostModel);
|
||||
|
||||
// Assign the new (and tracked) photo to the new post
|
||||
BlogPostModel.MainPhoto = newPhoto!;
|
||||
context.BlogPosts.Add(BlogPostModel);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Blog post exists in database
|
||||
// --- 3. Update Existing Post ---
|
||||
BlogPost? existing = await context.BlogPosts
|
||||
.Include(p => p.Tags)
|
||||
.Include(p => p.MainPhoto) // Load the existing photo
|
||||
.Include(p=>p.ArtifactGroupings)
|
||||
.FirstOrDefaultAsync(p => p.Id == BlogPostModel.Id);
|
||||
|
||||
// Find the existing post so we can update it
|
||||
|
||||
BlogPost? existing = await context.BlogPosts.Include(p=>p.Tags).FirstOrDefaultAsync(p => p.Id == BlogPostModel.Id);
|
||||
if (existing is null)
|
||||
{
|
||||
Snackbar.Add($"Unable to find a post with id {BlogPostModel.Id} in the database, cannot update post!", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// De-duplicate tags
|
||||
await DeDuplicatePostTags(BlogPostModel, context);
|
||||
|
||||
// Copy over the values we want
|
||||
existing.Title = BlogPostModel.Title;
|
||||
existing.Content = BlogPostModel.Content;
|
||||
existing.ModifiedTime = BlogPostModel.ModifiedTime;
|
||||
existing.ArtifactGroupings = BlogPostModel.ArtifactGroupings;
|
||||
|
||||
// --- 4. Handle Photo Swap (THIS IS THE FIX) ---
|
||||
if (newPhoto is not null)
|
||||
{
|
||||
// A new photo was uploaded.
|
||||
// Save the *tracked* old photo for deletion later.
|
||||
if (existing.MainPhoto is not null)
|
||||
{
|
||||
oldPhotoToDelete = existing.MainPhoto;
|
||||
}
|
||||
|
||||
// Assign the *new, tracked* photo.
|
||||
existing.MainPhoto = newPhoto;
|
||||
}
|
||||
// If newPhoto is null, we do nothing. existing.MainPhoto remains unchanged.
|
||||
// This avoids the tracking conflict.
|
||||
|
||||
existing.Tags.Clear();
|
||||
foreach (var tag in BlogPostModel.Tags)
|
||||
@@ -173,14 +301,42 @@
|
||||
BuildBlogSearchIndex(existing);
|
||||
}
|
||||
|
||||
// --- 5. Save Primary Changes ---
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// --- 6. Old File Cleanup ---
|
||||
if (oldPhotoToDelete is not null)
|
||||
{
|
||||
// Now that the DB save is successful, delete the old file
|
||||
try
|
||||
{
|
||||
if (File.Exists(oldPhotoToDelete.Path))
|
||||
{
|
||||
File.Delete(oldPhotoToDelete.Path);
|
||||
}
|
||||
|
||||
// And remove its record (which is tracked) from the database
|
||||
context.ArtifactFilePaths.Remove(oldPhotoToDelete);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error deleting old photo: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add("Changes saved!", Severity.Success);
|
||||
|
||||
// Reset the model
|
||||
// --- 7. Reset Page ---
|
||||
BlogPostModel = new();
|
||||
_mainPhotoFile = null;
|
||||
|
||||
// We can't navigate yet, we need to clear the groupings
|
||||
await _artifactGroupingTable.SetSelectedItemsAsync(new List<ArtifactGrouping>());
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
NavigationManager.NavigateTo($"/admin/blogstable");
|
||||
}
|
||||
|
||||
public void HandleTagsEnter(KeyboardEventArgs args)
|
||||
@@ -192,7 +348,8 @@
|
||||
|
||||
BlogPostModel.Tags.Add(newTag);
|
||||
|
||||
StateHasChanged();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user