From bf4182f11516851ca9eed0df16ddd83fbd3b9187 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:35:22 -0500 Subject: [PATCH] add copy block button (#2518) --- CHANGELOG.md | 1 + .../Scheduling/Commands/CopyBlock.cs | 6 + .../Scheduling/Commands/CopyBlockHandler.cs | 118 ++++++++++++++++++ ErsatzTV/Pages/Blocks.razor | 22 +++- ErsatzTV/Shared/CopyBlockDialog.razor | 100 +++++++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Application/Scheduling/Commands/CopyBlock.cs create mode 100644 ErsatzTV.Application/Scheduling/Commands/CopyBlockHandler.cs create mode 100644 ErsatzTV/Shared/CopyBlockDialog.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3932c65..bc6ca6c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `Block Playout Troubleshooting` tool to help investigate block playout history - Add sequential schedule file and scripted schedule file names to playouts table - Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs +- Add button to copy/clone block from blocks table ### Fixed - Fix NVIDIA startup errors on arm64 diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyBlock.cs b/ErsatzTV.Application/Scheduling/Commands/CopyBlock.cs new file mode 100644 index 000000000..adc6b6196 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyBlock.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Scheduling; + +public record CopyBlock(int BlockId, int NewBlockGroupId, string NewBlockName) + : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyBlockHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CopyBlockHandler.cs new file mode 100644 index 000000000..47746b1a1 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyBlockHandler.cs @@ -0,0 +1,118 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Scheduling.Mapper; + +namespace ErsatzTV.Application.Scheduling; + +public class CopyBlockHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + CopyBlock request, + CancellationToken cancellationToken) + { + try + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request, cancellationToken); + return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken)); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + return BaseError.New(ex.Message); + } + } + + private static async Task PerformCopy( + TvContext dbContext, + Block block, + CopyBlock request, + CancellationToken cancellationToken) + { + DetachEntity(dbContext, block); + block.Name = request.NewBlockName; + block.BlockGroup = null; + block.BlockGroupId = request.NewBlockGroupId; + + foreach (BlockItem item in block.Items) + { + DetachEntity(dbContext, item); + item.BlockId = 0; + item.Block = block; + + foreach (BlockItemWatermark watermark in item.BlockItemWatermarks) + { + DetachEntity(dbContext, watermark); + watermark.BlockItemId = 0; + watermark.BlockItem = item; + } + + foreach (BlockItemGraphicsElement graphicsElement in item.BlockItemGraphicsElements) + { + DetachEntity(dbContext, graphicsElement); + graphicsElement.BlockItemId = 0; + graphicsElement.BlockItem = item; + } + } + + await dbContext.Blocks.AddAsync(block, cancellationToken); + await dbContext.BlockItems.AddRangeAsync(block.Items, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + + await dbContext.Entry(block).Reference(b => b.BlockGroup).LoadAsync(cancellationToken); + + return ProjectToViewModel(block); + } + + private static async Task> Validate( + TvContext dbContext, + CopyBlock request, + CancellationToken cancellationToken) => + (await BlockMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) + .Apply((block, _) => block); + + private static Task> BlockMustExist( + TvContext dbContext, + CopyBlock request, + CancellationToken cancellationToken) => + dbContext.Blocks + .AsNoTracking() + .Include(ps => ps.Items) + .ThenInclude(i => i.BlockItemWatermarks) + .Include(ps => ps.Items) + .ThenInclude(i => i.BlockItemGraphicsElements) + .SelectOneAsync(p => p.Id, p => p.Id == request.BlockId, cancellationToken) + .Map(o => o.ToValidation("Block does not exist.")); + + private static async Task> ValidateName(TvContext dbContext, CopyBlock request) + { + List allNames = await dbContext.Blocks + .Where(b => b.BlockGroupId == request.NewBlockGroupId) + .Map(ps => ps.Name) + .ToListAsync(); + + Validation result1 = request.NotEmpty(c => c.NewBlockName) + .Bind(_ => request.NotLongerThan(50)(c => c.NewBlockName)); + + var result2 = Optional(request.NewBlockName) + .Where(name => !allNames.Contains(name)) + .ToValidation("Block name must be unique within the block group."); + + return (result1, result2).Apply((_, _) => request.NewBlockName); + } + + private static void DetachEntity(TvContext db, T entity) where T : class + { + db.Entry(entity).State = EntityState.Detached; + if (entity.GetType().GetProperty("Id") is not null) + { + entity.GetType().GetProperty("Id")!.SetValue(entity, 0); + } + } +} diff --git a/ErsatzTV/Pages/Blocks.razor b/ErsatzTV/Pages/Blocks.razor index 13b74faa0..97516142a 100644 --- a/ErsatzTV/Pages/Blocks.razor +++ b/ErsatzTV/Pages/Blocks.razor @@ -5,6 +5,7 @@ @inject ISnackbar Snackbar @inject IMediator Mediator @inject IDialogService Dialog +@inject NavigationManager NavigationManager
@@ -60,7 +61,7 @@ - + @@ -78,6 +79,7 @@
+
+ + + + @@ -258,6 +265,19 @@ } } + private async Task CopyBlock(BlockViewModel block) + { + var parameters = new DialogParameters { { "BlockId", block.Id } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = await Dialog.ShowAsync("Copy Block", parameters, options); + DialogResult dialogResult = await dialog.Result; + if (dialogResult is { Canceled: false, Data: BlockViewModel data }) + { + NavigationManager.NavigateTo($"blocks/{data.Id}"); + } + } + private async Task DeleteBlock(BlockViewModel block) { var parameters = new DialogParameters { { "EntityType", "block" }, { "EntityName", block.Name } }; diff --git a/ErsatzTV/Shared/CopyBlockDialog.razor b/ErsatzTV/Shared/CopyBlockDialog.razor new file mode 100644 index 000000000..d22b70937 --- /dev/null +++ b/ErsatzTV/Shared/CopyBlockDialog.razor @@ -0,0 +1,100 @@ +@using ErsatzTV.Application.Scheduling +@implements IDisposable +@inject IMediator Mediator +@inject ISnackbar Snackbar +@inject ILogger Logger + + + + +
+ Select a group for the new block +
+ + @foreach (BlockGroupViewModel blockGroup in _blockGroups) + { + @blockGroup.Name + } + +
+ Enter a name for the new block +
+ + +
+
+ + Cancel + + Copy Block + + +
+ +@code { + private CancellationTokenSource _cts; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } + + [Parameter] + public int BlockId { get; set; } + + private record DummyModel; + + private readonly DummyModel _dummyModel = new(); + + private List _blockGroups = []; + private BlockGroupViewModel _selectedBlockGroup; + + private string _newName; + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + } + + protected override async Task OnParametersSetAsync() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + var token = _cts.Token; + + try + { + _blockGroups = await Mediator.Send(new GetAllBlockGroups(), token); + } + catch (OperationCanceledException) + { + // do nothing + } + } + + private bool CanSubmit() => _selectedBlockGroup != null && !string.IsNullOrWhiteSpace(_newName); + + private async Task Submit() + { + if (!CanSubmit()) + { + return; + } + + Either maybeResult = + await Mediator.Send(new CopyBlock(BlockId, _selectedBlockGroup.Id, _newName), _cts.Token); + + maybeResult.Match( + schedule => { MudDialog.Close(DialogResult.Ok(schedule)); }, + error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Error copying block: {Error}", error.Value); + MudDialog.Close(DialogResult.Cancel()); + }); + } + + private void Cancel(MouseEventArgs e) => MudDialog.Cancel(); +}