@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
|
||||
new(fillerPreset.Id, fillerPreset.Name);
|
||||
new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind);
|
||||
|
||||
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
|
||||
|
||||
@@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
|
||||
if (request.FillerKind is { } fillerKind)
|
||||
{
|
||||
query = query.Where(fp => fp.FillerKind == fillerKind);
|
||||
}
|
||||
|
||||
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,15 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(item);
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
|
||||
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229).
|
||||
ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.Id == item.Id)
|
||||
.IncludeScheduleItemDetails()
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
return ProjectToViewModel(persisted);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
|
||||
+10
-1
@@ -56,7 +56,16 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return programSchedule.Items.Map(ProjectToViewModel);
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
|
||||
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229).
|
||||
List<ProgramScheduleItem> persisted = await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.ProgramScheduleId == programSchedule.Id)
|
||||
.IncludeScheduleItemDetails()
|
||||
.OrderBy(i => i.Index)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return persisted.Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
internal static class ProgramScheduleItemQueryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The single source of truth for the navigation graph a <see cref="ProgramScheduleItem" /> needs before
|
||||
/// it can be projected via <see cref="Mapper.ProjectToViewModel" />. The mapper dereferences the
|
||||
/// <c>Watermark</c> / <c>GraphicsElement</c> navs of each join row without a null guard, so any handler
|
||||
/// that projects freshly-persisted items MUST reload through this include chain (see #229 — the write
|
||||
/// path used to project the tracked-but-unloaded graph and threw a NullReferenceException, surfacing as a
|
||||
/// 500 on PUT/POST whenever an item carried a watermark or graphics element).
|
||||
/// </summary>
|
||||
public static IQueryable<ProgramScheduleItem> IncludeScheduleItemDetails(this IQueryable<ProgramScheduleItem> query) =>
|
||||
query
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.SmartCollection)
|
||||
.Include(i => i.RerunCollection)
|
||||
.Include(i => i.Playlist)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.Include(i => i.PreRollFiller)
|
||||
.Include(i => i.MidRollFiller)
|
||||
.Include(i => i.PostRollFiller)
|
||||
.Include(i => i.TailFiller)
|
||||
.Include(i => i.FallbackFiller)
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.ThenInclude(i => i.Watermark)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.ThenInclude(i => i.GraphicsElement);
|
||||
}
|
||||
@@ -20,36 +20,8 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory<TvContext> dbConte
|
||||
|
||||
return await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.ProgramScheduleId == request.Id)
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.SmartCollection)
|
||||
.Include(i => i.RerunCollection)
|
||||
.Include(i => i.Playlist)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.Include(i => i.PreRollFiller)
|
||||
.Include(i => i.MidRollFiller)
|
||||
.Include(i => i.PostRollFiller)
|
||||
.Include(i => i.TailFiller)
|
||||
.Include(i => i.FallbackFiller)
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.ThenInclude(i => i.Watermark)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.ThenInclude(i => i.GraphicsElement)
|
||||
.IncludeScheduleItemDetails()
|
||||
.OrderBy(i => i.Index)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(programScheduleItems => programScheduleItems.Map(ProjectToViewModel)
|
||||
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Api;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
|
||||
/// fully-described <see cref="ScheduleItemResponseModel" /> API DTO (issue #126). Subtype-specific
|
||||
/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are
|
||||
/// read by pattern-matching the concrete VM type; they are null for other subtypes.
|
||||
/// </summary>
|
||||
public static class ScheduleItemResponseMapper
|
||||
{
|
||||
public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item)
|
||||
{
|
||||
MultipleMode? multipleMode = null;
|
||||
string multipleCount = null;
|
||||
TimeSpan? playoutDuration = null;
|
||||
TailMode? tailMode = null;
|
||||
int? discardToFillAttempts = null;
|
||||
|
||||
switch (item)
|
||||
{
|
||||
case ProgramScheduleItemMultipleViewModel multiple:
|
||||
multipleMode = multiple.MultipleMode;
|
||||
multipleCount = multiple.Count;
|
||||
break;
|
||||
case ProgramScheduleItemDurationViewModel duration:
|
||||
playoutDuration = duration.PlayoutDuration;
|
||||
tailMode = duration.TailMode;
|
||||
discardToFillAttempts = duration.DiscardToFillAttempts;
|
||||
break;
|
||||
}
|
||||
|
||||
List<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
|
||||
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
|
||||
List<NamedIdResponseModel> watermarks =
|
||||
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
|
||||
?? new List<NamedIdResponseModel>();
|
||||
List<NamedIdResponseModel> graphicsElements =
|
||||
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
|
||||
?? new List<NamedIdResponseModel>();
|
||||
|
||||
return new ScheduleItemResponseModel(
|
||||
item.Id,
|
||||
item.Index,
|
||||
item.StartType,
|
||||
item.StartTime,
|
||||
item.FixedStartTimeBehavior,
|
||||
item.PlayoutMode,
|
||||
item.CollectionType,
|
||||
item.Collection?.Id,
|
||||
item.MultiCollection?.Id,
|
||||
item.SmartCollection?.Id,
|
||||
item.RerunCollection?.Id,
|
||||
item.MediaItem?.MediaItemId,
|
||||
item.Playlist?.Id,
|
||||
item.SearchTitle,
|
||||
item.SearchQuery,
|
||||
item.PlaybackOrder,
|
||||
item.MarathonGroupBy,
|
||||
item.MarathonShuffleGroups,
|
||||
item.MarathonShuffleItems,
|
||||
item.MarathonBatchSize,
|
||||
item.FillWithGroupMode,
|
||||
multipleMode,
|
||||
multipleCount,
|
||||
playoutDuration,
|
||||
tailMode,
|
||||
discardToFillAttempts,
|
||||
item.CustomTitle,
|
||||
item.GuideMode,
|
||||
item.PreRollFiller?.Id,
|
||||
item.MidRollFiller?.Id,
|
||||
item.PostRollFiller?.Id,
|
||||
item.TailFiller?.Id,
|
||||
item.FallbackFiller?.Id,
|
||||
watermarkIds,
|
||||
graphicsElementIds,
|
||||
item.PreferredAudioLanguageCode,
|
||||
item.PreferredAudioTitle,
|
||||
item.PreferredSubtitleLanguageCode,
|
||||
item.SubtitleMode,
|
||||
item.Collection?.Name,
|
||||
item.MultiCollection?.Name,
|
||||
item.SmartCollection?.Name,
|
||||
item.RerunCollection?.Name,
|
||||
item.Playlist?.Name,
|
||||
item.Playlist?.PlaylistGroupId,
|
||||
item.MediaItem?.Name,
|
||||
item.PreRollFiller?.Name,
|
||||
item.MidRollFiller?.Name,
|
||||
item.PostRollFiller?.Name,
|
||||
item.TailFiller?.Name,
|
||||
item.FallbackFiller?.Name,
|
||||
watermarks,
|
||||
graphicsElements,
|
||||
item.Name,
|
||||
item.DurationEstimate);
|
||||
}
|
||||
|
||||
public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) =>
|
||||
new(
|
||||
vm.Items.Map(ProjectToResponseModel).ToList(),
|
||||
vm.TotalDurationEstimate);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Filler;
|
||||
|
||||
public record FillerPresetResponseModel(int Id, string Name);
|
||||
public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaItems;
|
||||
|
||||
public record LanguageCodeResponseModel(string Code, string EnglishName);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api;
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight {id, name} pair used when an API response needs to embed a reference to a
|
||||
/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting
|
||||
/// the full entity DTO.
|
||||
/// </summary>
|
||||
public record NamedIdResponseModel(int Id, string Name);
|
||||
@@ -0,0 +1,70 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the
|
||||
/// Application-layer <c>ProgramScheduleItemViewModel</c> hierarchy (One/Flood/Multiple/Duration
|
||||
/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully
|
||||
/// describes the shape. The mutation fields are named 1:1 with
|
||||
/// <c>ScheduleItemRequest</c> so a GET can be mapped losslessly back to a PUT/POST request.
|
||||
/// </summary>
|
||||
public record ScheduleItemResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
StartType StartType,
|
||||
TimeSpan? StartTime,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior,
|
||||
PlayoutMode PlayoutMode,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string? SearchTitle,
|
||||
string? SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
MarathonGroupBy MarathonGroupBy,
|
||||
bool MarathonShuffleGroups,
|
||||
bool MarathonShuffleItems,
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode? MultipleMode,
|
||||
string? MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode? TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
string? CustomTitle,
|
||||
GuideMode GuideMode,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
int? TailFillerId,
|
||||
int? FallbackFillerId,
|
||||
List<int> WatermarkIds,
|
||||
List<int> GraphicsElementIds,
|
||||
string? PreferredAudioLanguageCode,
|
||||
string? PreferredAudioTitle,
|
||||
string? PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode? SubtitleMode,
|
||||
string? CollectionName,
|
||||
string? MultiCollectionName,
|
||||
string? SmartCollectionName,
|
||||
string? RerunCollectionName,
|
||||
string? PlaylistName,
|
||||
int? PlaylistGroupId,
|
||||
string? MediaItemName,
|
||||
string? PreRollFillerName,
|
||||
string? MidRollFillerName,
|
||||
string? PostRollFillerName,
|
||||
string? TailFillerName,
|
||||
string? FallbackFillerName,
|
||||
List<NamedIdResponseModel> Watermarks,
|
||||
List<NamedIdResponseModel> GraphicsElements,
|
||||
string? Name,
|
||||
TimeSpan? DurationEstimate);
|
||||
@@ -0,0 +1,12 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// GET envelope for a schedule's items. Carries the flat <see cref="ScheduleItemResponseModel" />
|
||||
/// list plus a best-effort total runtime estimate (null when unbounded/unknown).
|
||||
/// </summary>
|
||||
public record ScheduleItemsResponseModel(
|
||||
List<ScheduleItemResponseModel> Items,
|
||||
TimeSpan? TotalDurationEstimate);
|
||||
@@ -32,8 +32,24 @@ public class FillerPresetHandlerTests
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
|
||||
result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll));
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind()
|
||||
{
|
||||
await SeedPreset(1, "Intro", FillerKind.PreRoll);
|
||||
await SeedPreset(2, "Mid", FillerKind.MidRoll);
|
||||
await SeedPreset(3, "Outro", FillerKind.PostRoll);
|
||||
|
||||
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
|
||||
|
||||
List<FillerPresetResponseModel> result =
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -47,14 +63,14 @@ public class FillerPresetHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedPreset(int id, string name)
|
||||
private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FillerPresets.Add(new FillerPreset
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
FillerKind = FillerKind.PreRoll,
|
||||
FillerKind = fillerKind,
|
||||
FillerMode = FillerMode.Duration,
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> produced by
|
||||
/// the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems)
|
||||
/// with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents
|
||||
/// the deliberate <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScheduleItemResponseRoundTripTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes()
|
||||
{
|
||||
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false);
|
||||
|
||||
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
// Arrange: establish the initial items through the real write path.
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded =
|
||||
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None);
|
||||
seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// Act: GET (with durations) → flat DTO envelope A
|
||||
ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId);
|
||||
envelopeA.Items.Count.ShouldBe(6);
|
||||
|
||||
// Map every response item back into a Replace command exactly as the request mapping would, then PUT.
|
||||
// Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list.
|
||||
List<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
|
||||
.OrderBy(item => item.Index)
|
||||
.Select((item, index) => ToReplaceCommand(item, index))
|
||||
.ToList();
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> replaced =
|
||||
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
|
||||
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
|
||||
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
|
||||
|
||||
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
|
||||
envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate);
|
||||
|
||||
// The GET does not guarantee row order, so pair items up by their (stable) Index before comparing.
|
||||
List<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
|
||||
List<ScheduleItemResponseModel> orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList();
|
||||
for (var i = 0; i < orderedA.Count; i++)
|
||||
{
|
||||
AssertSemanticallyEqual(orderedA[i], orderedB[i]);
|
||||
}
|
||||
|
||||
// Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous).
|
||||
ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple);
|
||||
multiple.CollectionName.ShouldBe("Prime Collection");
|
||||
multiple.MultipleMode.ShouldBe(MultipleMode.Count);
|
||||
multiple.MultipleCount.ShouldBe("2 + 1");
|
||||
multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups);
|
||||
multiple.PreRollFillerId.ShouldBe(1);
|
||||
multiple.PreRollFillerName.ShouldBe("PreRoll");
|
||||
multiple.FallbackFillerId.ShouldBe(5);
|
||||
multiple.WatermarkIds.ShouldBe([1, 2]);
|
||||
multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
multiple.GraphicsElementIds.ShouldBe([1, 2]);
|
||||
multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]);
|
||||
multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
|
||||
|
||||
ScheduleItemResponseModel one = envelopeA.Items.Single(i =>
|
||||
i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection);
|
||||
one.StartType.ShouldBe(StartType.Fixed);
|
||||
one.StartTime.ShouldBe(TimeSpan.FromHours(20));
|
||||
one.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible);
|
||||
one.CustomTitle.ShouldBe("News Hour");
|
||||
one.PreferredAudioLanguageCode.ShouldBe("eng");
|
||||
one.PreferredAudioTitle.ShouldBe("Director Commentary");
|
||||
one.PreferredSubtitleLanguageCode.ShouldBe("fra");
|
||||
one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any);
|
||||
|
||||
ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration);
|
||||
duration.SmartCollectionName.ShouldBe("Smart Picks");
|
||||
duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45));
|
||||
duration.TailMode.ShouldBe(TailMode.Filler);
|
||||
duration.TailFillerId.ShouldBe(4);
|
||||
duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon);
|
||||
duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show);
|
||||
duration.MarathonBatchSize.ShouldBe(4);
|
||||
// Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes
|
||||
// it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side
|
||||
// normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so
|
||||
// the re-submit maps 0 → 0 and B stays equal.
|
||||
duration.DiscardToFillAttempts.ShouldBe(0);
|
||||
|
||||
ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood);
|
||||
flood.PlaylistName.ShouldBe("My Playlist");
|
||||
flood.PlaylistGroupId.ShouldBe(9);
|
||||
flood.MarathonShuffleGroups.ShouldBeTrue();
|
||||
|
||||
ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun);
|
||||
rerun.RerunCollectionName.ShouldBe("Rerun Bin");
|
||||
|
||||
ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery);
|
||||
search.SearchQuery.ShouldBe("genre:comedy");
|
||||
search.Name.ShouldBe("Comedy Search");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled()
|
||||
{
|
||||
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true);
|
||||
|
||||
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded = await replaceHandler.Handle(
|
||||
new ReplaceProgramScheduleItems(
|
||||
scheduleId,
|
||||
[
|
||||
// Fixed-start Flood item on a plain collection.
|
||||
MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
// Playlist item carrying a playback order that must be normalized away.
|
||||
MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with
|
||||
{
|
||||
PlaylistId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
]),
|
||||
CancellationToken.None);
|
||||
seeded.IsRight.ShouldBeTrue();
|
||||
|
||||
List<ScheduleItemResponseModel> items = (await GetItemsEnvelope(scheduleId)).Items;
|
||||
|
||||
ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection);
|
||||
// Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on.
|
||||
floodItem.PlayoutMode.ShouldBe(PlayoutMode.One);
|
||||
floodItem.StartType.ShouldBe(StartType.Dynamic);
|
||||
|
||||
ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist);
|
||||
playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None);
|
||||
}
|
||||
|
||||
private async Task<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
|
||||
{
|
||||
var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
|
||||
IMediator mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));
|
||||
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());
|
||||
|
||||
var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
|
||||
ProgramScheduleItemsWithDurationViewModel vm =
|
||||
await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
|
||||
return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
|
||||
}
|
||||
|
||||
private static List<ReplaceProgramScheduleItem> BuildSeedItems() =>
|
||||
[
|
||||
// 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title.
|
||||
MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with
|
||||
{
|
||||
StartType = StartType.Fixed,
|
||||
StartTime = TimeSpan.FromHours(20),
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "News Hour",
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
PreferredAudioTitle = "Director Commentary",
|
||||
PreferredSubtitleLanguageCode = "fra",
|
||||
SubtitleMode = ChannelSubtitleMode.Any
|
||||
},
|
||||
// 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge.
|
||||
MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with
|
||||
{
|
||||
CollectionId = 2,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
MultipleCount = "2 + 1",
|
||||
PreRollFillerId = 1,
|
||||
MidRollFillerId = 2,
|
||||
PostRollFillerId = 3,
|
||||
TailFillerId = 4,
|
||||
FallbackFillerId = 5,
|
||||
WatermarkIds = [1, 2],
|
||||
GraphicsElementIds = [1, 2]
|
||||
},
|
||||
// 2: Duration on a smart collection, Marathon order + tail filler.
|
||||
MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with
|
||||
{
|
||||
SmartCollectionId = 1,
|
||||
PlayoutDuration = TimeSpan.FromMinutes(45),
|
||||
TailMode = TailMode.Filler,
|
||||
TailFillerId = 4,
|
||||
DiscardToFillAttempts = 5,
|
||||
PlaybackOrder = PlaybackOrder.Marathon,
|
||||
MarathonGroupBy = MarathonGroupBy.Show,
|
||||
MarathonShuffleGroups = true,
|
||||
MarathonShuffleItems = true,
|
||||
MarathonBatchSize = 4
|
||||
},
|
||||
// 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items).
|
||||
MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with
|
||||
{
|
||||
PlaylistId = 1,
|
||||
PlaybackOrder = PlaybackOrder.None,
|
||||
MarathonShuffleGroups = true
|
||||
},
|
||||
// 4: Rerun-first-run One item.
|
||||
MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with
|
||||
{
|
||||
RerunCollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.None
|
||||
},
|
||||
// 5: SearchQuery One item.
|
||||
MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with
|
||||
{
|
||||
SearchTitle = "Comedy Search",
|
||||
SearchQuery = "genre:comedy",
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
];
|
||||
|
||||
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) =>
|
||||
new(
|
||||
index,
|
||||
StartType.Dynamic,
|
||||
StartTime: null,
|
||||
FixedStartTimeBehavior: null,
|
||||
playoutMode,
|
||||
collectionType,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null,
|
||||
SearchTitle: null,
|
||||
SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false,
|
||||
MarathonShuffleItems: false,
|
||||
MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None,
|
||||
MultipleMode: MultipleMode.Count,
|
||||
MultipleCount: null,
|
||||
PlayoutDuration: null,
|
||||
TailMode: TailMode.None,
|
||||
DiscardToFillAttempts: null,
|
||||
CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal,
|
||||
PreRollFillerId: null,
|
||||
MidRollFillerId: null,
|
||||
PostRollFillerId: null,
|
||||
TailFillerId: null,
|
||||
FallbackFillerId: null,
|
||||
WatermarkIds: [],
|
||||
GraphicsElementIds: [],
|
||||
PreferredAudioLanguageCode: null,
|
||||
PreferredAudioTitle: null,
|
||||
PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
// Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a
|
||||
// response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT).
|
||||
private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) =>
|
||||
new(
|
||||
index,
|
||||
r.StartType,
|
||||
r.StartTime,
|
||||
r.FixedStartTimeBehavior,
|
||||
r.PlayoutMode,
|
||||
r.CollectionType,
|
||||
r.CollectionId,
|
||||
r.MultiCollectionId,
|
||||
r.SmartCollectionId,
|
||||
r.RerunCollectionId,
|
||||
r.MediaItemId,
|
||||
r.PlaylistId,
|
||||
r.SearchTitle,
|
||||
r.SearchQuery,
|
||||
r.PlaybackOrder,
|
||||
r.MarathonGroupBy,
|
||||
r.MarathonShuffleGroups,
|
||||
r.MarathonShuffleItems,
|
||||
r.MarathonBatchSize,
|
||||
r.FillWithGroupMode,
|
||||
r.MultipleMode ?? MultipleMode.Count,
|
||||
r.MultipleCount,
|
||||
r.PlayoutDuration,
|
||||
r.TailMode ?? TailMode.None,
|
||||
r.DiscardToFillAttempts,
|
||||
r.CustomTitle,
|
||||
r.GuideMode,
|
||||
r.PreRollFillerId,
|
||||
r.MidRollFillerId,
|
||||
r.PostRollFillerId,
|
||||
r.TailFillerId,
|
||||
r.FallbackFillerId,
|
||||
r.WatermarkIds,
|
||||
r.GraphicsElementIds,
|
||||
r.PreferredAudioLanguageCode,
|
||||
r.PreferredAudioTitle,
|
||||
r.PreferredSubtitleLanguageCode,
|
||||
r.SubtitleMode);
|
||||
|
||||
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
|
||||
{
|
||||
b.Index.ShouldBe(a.Index);
|
||||
b.StartType.ShouldBe(a.StartType);
|
||||
b.StartTime.ShouldBe(a.StartTime);
|
||||
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
|
||||
b.PlayoutMode.ShouldBe(a.PlayoutMode);
|
||||
b.CollectionType.ShouldBe(a.CollectionType);
|
||||
b.CollectionId.ShouldBe(a.CollectionId);
|
||||
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
|
||||
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
|
||||
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
|
||||
b.MediaItemId.ShouldBe(a.MediaItemId);
|
||||
b.PlaylistId.ShouldBe(a.PlaylistId);
|
||||
b.SearchTitle.ShouldBe(a.SearchTitle);
|
||||
b.SearchQuery.ShouldBe(a.SearchQuery);
|
||||
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
|
||||
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
|
||||
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
|
||||
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
|
||||
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
|
||||
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
|
||||
b.MultipleMode.ShouldBe(a.MultipleMode);
|
||||
b.MultipleCount.ShouldBe(a.MultipleCount);
|
||||
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
|
||||
b.TailMode.ShouldBe(a.TailMode);
|
||||
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
|
||||
b.CustomTitle.ShouldBe(a.CustomTitle);
|
||||
b.GuideMode.ShouldBe(a.GuideMode);
|
||||
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
|
||||
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
|
||||
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
|
||||
b.TailFillerId.ShouldBe(a.TailFillerId);
|
||||
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
|
||||
b.WatermarkIds.ShouldBe(a.WatermarkIds);
|
||||
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
|
||||
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
|
||||
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
|
||||
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
|
||||
b.SubtitleMode.ShouldBe(a.SubtitleMode);
|
||||
b.CollectionName.ShouldBe(a.CollectionName);
|
||||
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
|
||||
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
|
||||
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
|
||||
b.PlaylistName.ShouldBe(a.PlaylistName);
|
||||
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
|
||||
b.MediaItemName.ShouldBe(a.MediaItemName);
|
||||
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
|
||||
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
|
||||
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
|
||||
b.TailFillerName.ShouldBe(a.TailFillerName);
|
||||
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
|
||||
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
|
||||
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
|
||||
b.Name.ShouldBe(a.Name);
|
||||
b.DurationEstimate.ShouldBe(a.DurationEstimate);
|
||||
}
|
||||
|
||||
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
context.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
|
||||
context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" });
|
||||
context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" });
|
||||
context.RerunCollections.Add(new RerunCollection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Rerun Bin",
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
|
||||
context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" });
|
||||
context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" });
|
||||
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
|
||||
context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
|
||||
context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
|
||||
|
||||
AddFiller(context, 1, "PreRoll", FillerKind.PreRoll);
|
||||
AddFiller(context, 2, "MidRoll", FillerKind.MidRoll);
|
||||
AddFiller(context, 3, "PostRoll", FillerKind.PostRoll);
|
||||
AddFiller(context, 4, "Tail", FillerKind.Tail);
|
||||
AddFiller(context, 5, "Fallback", FillerKind.Fallback);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Round Trip",
|
||||
ShuffleScheduleItems = shuffleScheduleItems,
|
||||
Items = [],
|
||||
Playouts = [],
|
||||
ProgramScheduleAlternates = []
|
||||
};
|
||||
context.ProgramSchedules.Add(schedule);
|
||||
await context.SaveChangesAsync();
|
||||
return schedule.Id;
|
||||
}
|
||||
|
||||
private static void AddFiller(TvContext context, int id, string name, FillerKind kind) =>
|
||||
context.FillerPresets.Add(new FillerPreset
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
FillerKind = kind,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for #229. The write path (Replace = PUT, Add = POST) used to project freshly-persisted
|
||||
/// items whose watermark/graphics join rows only carry foreign-key ids — the Watermark / GraphicsElement navs
|
||||
/// are null, and <c>Mapper.ProjectToViewModel</c> dereferences them without a guard, throwing an NRE that
|
||||
/// surfaced as a 500. The pre-existing round-trip test masked this because the Replace handler returned a
|
||||
/// <b>lazy</b> LanguageExt <c>Map</c> sequence and that test only checked <c>.IsRight</c> — it never enumerated
|
||||
/// the projected items, so the deferred NRE never fired. These tests force enumeration of the write-path
|
||||
/// response (exactly as the controller's <c>.ToList()</c> / single-item serialization does) and seed the
|
||||
/// watermark/graphics entities through a <b>separate</b> context so the handler's fresh factory context has
|
||||
/// nothing pre-tracked (no accidental change-tracker fix-up).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScheduleItemWriteProjectionTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Replace_PutResponse_Should_Carry_Watermark_And_Graphics_Names()
|
||||
{
|
||||
int scheduleId = await SeedReferences();
|
||||
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result = await handler.Handle(
|
||||
new ReplaceProgramScheduleItems(scheduleId, [ItemWithGraphics(0)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// Force enumeration exactly like the controller (`items.Select(...).ToList()`) — this is what threw the NRE.
|
||||
List<ProgramScheduleItemViewModel> items = result.RightToSeq().Head().ToList();
|
||||
ProgramScheduleItemViewModel item = items.Single();
|
||||
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
item.GraphicsElements.Count.ShouldBe(2);
|
||||
item.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_PostResponse_Should_Carry_Watermark_And_Graphics_Names()
|
||||
{
|
||||
int scheduleId = await SeedReferences();
|
||||
|
||||
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result = await handler.Handle(
|
||||
AddItemWithGraphics(scheduleId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
ProgramScheduleItemViewModel item = result.RightToSeq().Head();
|
||||
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
item.GraphicsElements.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Should_Return_Items_In_Index_Order_Regardless_Of_Id_Order()
|
||||
{
|
||||
int scheduleId;
|
||||
await using (TvContext ctx = _db.CreateContext())
|
||||
{
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Shuffled", Items = [], Playouts = [], ProgramScheduleAlternates = []
|
||||
};
|
||||
ctx.ProgramSchedules.Add(schedule);
|
||||
await ctx.SaveChangesAsync();
|
||||
scheduleId = schedule.Id;
|
||||
|
||||
// Insert with ids deliberately out of Index order: id 10 -> Index 2, id 11 -> Index 0, id 12 -> Index 1.
|
||||
// Before the fix the GET returned id order (2, 0, 1); after, it must return Index order (0, 1, 2).
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(10, scheduleId, index: 2));
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(11, scheduleId, index: 0));
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(12, scheduleId, index: 1));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var getHandler = new GetProgramScheduleItemsHandler(_db.Factory);
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await getHandler.Handle(new GetProgramScheduleItems(scheduleId), CancellationToken.None);
|
||||
|
||||
items.Select(i => i.Index).ShouldBe([0, 1, 2]);
|
||||
}
|
||||
|
||||
private static ProgramScheduleItemOne OneItemRow(int id, int scheduleId, int index) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
ProgramScheduleId = scheduleId,
|
||||
Index = index,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
ProgramScheduleItemWatermarks = [],
|
||||
ProgramScheduleItemGraphicsElements = []
|
||||
};
|
||||
|
||||
private async Task<int> SeedReferences()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
|
||||
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
|
||||
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
|
||||
ctx.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
|
||||
ctx.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "WM Schedule", Items = [], Playouts = [], ProgramScheduleAlternates = []
|
||||
};
|
||||
ctx.ProgramSchedules.Add(schedule);
|
||||
await ctx.SaveChangesAsync();
|
||||
return schedule.Id;
|
||||
}
|
||||
|
||||
private static ReplaceProgramScheduleItem ItemWithGraphics(int index) =>
|
||||
new(
|
||||
index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
|
||||
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
|
||||
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
|
||||
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
|
||||
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
|
||||
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
private static AddProgramScheduleItem AddItemWithGraphics(int scheduleId) =>
|
||||
new(
|
||||
scheduleId, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
|
||||
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
|
||||
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
|
||||
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
|
||||
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
|
||||
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Templates;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
@@ -59,6 +62,40 @@ public class ChannelControllerTests
|
||||
result.ShouldBe(new List<ChannelStateResponseModel> { state });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id()
|
||||
{
|
||||
MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!;
|
||||
mvct.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/music-video-credits-templates");
|
||||
|
||||
MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!;
|
||||
selectors.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/stream-selectors");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetMusicVideoCreditsTemplates_Should_Return_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMusicVideoCreditTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["Default", "Scroll"]);
|
||||
|
||||
List<string> result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["Default", "Scroll"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetStreamSelectors_Should_Return_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["selector-a.yml"]);
|
||||
|
||||
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["selector-a.yml"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
|
||||
@@ -63,17 +63,30 @@ public class FillerPresetControllerTests
|
||||
{
|
||||
List<FillerPresetResponseModel> models =
|
||||
[
|
||||
new FillerPresetResponseModel(1, "Intro"),
|
||||
new FillerPresetResponseModel(2, "Outro")
|
||||
new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll),
|
||||
new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll)
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(null, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Forward_FillerKind_Filter()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetAllFillerPresetsForApi>(q => q.FillerKind == FillerKind.MidRoll),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LanguagesControllerTests
|
||||
{
|
||||
private LanguagesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LanguagesController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!;
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/languages");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLanguages_Should_Map_Vm_To_ResponseModel()
|
||||
{
|
||||
List<LanguageCodeViewModel> vms =
|
||||
[
|
||||
new LanguageCodeViewModel("eng", "English"),
|
||||
new LanguageCodeViewModel("fra", "French")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllLanguageCodes>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<LanguageCodeResponseModel> result = await _controller.GetLanguages(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(
|
||||
[
|
||||
new LanguageCodeResponseModel("eng", "English"),
|
||||
new LanguageCodeResponseModel("fra", "French")
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using ErsatzTV.Core.Api.Settings;
|
||||
@@ -39,7 +38,10 @@ public class OpenApiSerializerContractTests
|
||||
yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel");
|
||||
yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel")
|
||||
.SetName("FFmpegSettingsResponseModel");
|
||||
yield return new TestCaseData(FullyPopulatedWatermark(), "WatermarkViewModel").SetName("WatermarkViewModel");
|
||||
// WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer
|
||||
// expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel
|
||||
// and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks
|
||||
// as NamedIdResponseModel. ChannelViewModel below still exercises the VM path of the schema transformer.
|
||||
yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel")
|
||||
.SetName("MediaItemInfoResponseModel");
|
||||
|
||||
@@ -119,24 +121,6 @@ public class OpenApiSerializerContractTests
|
||||
default,
|
||||
"script");
|
||||
|
||||
private static WatermarkViewModel FullyPopulatedWatermark() => new(
|
||||
1,
|
||||
new ArtworkContentTypeModel("path", "image/png"),
|
||||
"Name",
|
||||
default,
|
||||
default,
|
||||
default,
|
||||
default,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
"expression",
|
||||
1);
|
||||
|
||||
private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new(
|
||||
1,
|
||||
"Title",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -197,7 +199,10 @@ public class ScheduleControllerTests
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(response);
|
||||
var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
|
||||
envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
envelope.Items.Count.ShouldBe(1);
|
||||
envelope.Items[0].Id.ShouldBe(11);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -225,7 +230,7 @@ public class ScheduleControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/schedules/4/items/12");
|
||||
created.Value.ShouldBe(item);
|
||||
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -254,7 +259,9 @@ public class ScheduleControllerTests
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
|
||||
var replaced = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<ScheduleItemResponseModel>>();
|
||||
replaced.Select(i => i.Id).ShouldBe([21, 22]);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceProgramScheduleItems>(c =>
|
||||
c.ProgramScheduleId == 4 &&
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Templates;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
@@ -47,6 +48,22 @@ public class ChannelController(
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available music video credits template names")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available channel stream selector names")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(
|
||||
[FromQuery] FillerKind? fillerKind,
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
|
||||
|
||||
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
|
||||
[Tags("Filler Presets")]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class LanguagesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/languages", Name = "GetLanguages")]
|
||||
[Tags("Languages")]
|
||||
[EndpointSummary("Get all available language codes")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
|
||||
{
|
||||
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
|
||||
return languages
|
||||
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -104,7 +106,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -116,14 +118,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
|
||||
ProgramScheduleItemsWithDurationViewModel items =
|
||||
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
|
||||
return new OkObjectResult(items);
|
||||
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
|
||||
}
|
||||
|
||||
[HttpPost("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Add a schedule item")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> AddItem(
|
||||
@@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result =
|
||||
await mediator.Send(request.ToAddCommand(id), cancellationToken);
|
||||
return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item);
|
||||
return result.ToCreatedResult(
|
||||
item => $"/api/schedules/{id}/items/{item.Id}",
|
||||
item => ScheduleItemResponseMapper.ProjectToResponseModel(item));
|
||||
}
|
||||
|
||||
[HttpPut("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Replace schedule items")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(IEnumerable<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceItems(
|
||||
@@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToUpdatedResult();
|
||||
return result
|
||||
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList())
|
||||
.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
|
||||
|
||||
+585
-587
File diff suppressed because it is too large
Load Diff
+29
-1
@@ -58,7 +58,24 @@ Exemplars:
|
||||
`ToReplaceCommand(int index)`) method that maps it to the Application-layer command type.
|
||||
- **`ErsatzTV.Application` has no nullable context** (no `<Nullable>` = C# default `disable` for
|
||||
that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable
|
||||
annotations to types living there; that's a Core/Api-layer-only convention.
|
||||
annotations to types living there; that's a Core/Api-layer-only convention. A static mapper that
|
||||
lives in `ErsatzTV.Application` but returns a Core/Api response DTO with nullable members is fine
|
||||
(e.g. `ScheduleItemResponseMapper`) — the nullability lives on the DTO record, not the mapper.
|
||||
- **Shared `{id, name}` embeds**: use `ErsatzTV.Core/Api/NamedIdResponseModel.cs`
|
||||
(`record NamedIdResponseModel(int Id, string Name)`) when a response DTO needs to embed a list of
|
||||
named references (e.g. a schedule item's `watermarks` / `graphicsElements`) rather than minting a
|
||||
one-off `(int, string)` record per domain.
|
||||
- **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic
|
||||
record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote
|
||||
every subtype field to a nullable top-level member on a flat response DTO and pattern-match the
|
||||
concrete VM in the mapper. Exemplar: `ScheduleItemResponseModel` (issue #126, see
|
||||
`docs/decisions.md` 2026-07-10). Keep the flat DTO's **mutation** fields named 1:1 with the
|
||||
matching request DTO so GET→PUT is lossless (guard with a round-trip handler test).
|
||||
- **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable
|
||||
enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on
|
||||
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
|
||||
`GET /api/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
|
||||
enum value is rejected by model binding (400) — no handler-side guard needed.
|
||||
|
||||
## 3. Error mapping
|
||||
|
||||
@@ -214,6 +231,17 @@ items), index items from **array order** in the request body rather than trustin
|
||||
index/order field. Exemplar: `ReplaceScheduleItemsRequest.ToCommand(scheduleId)` —
|
||||
`Items.Select((item, index) => item.ToReplaceCommand(index))`.
|
||||
|
||||
**Project the write-path response through the same include chain the GET uses — never off the
|
||||
freshly-built graph.** After `SaveChanges`, a command's entities carry only the foreign-key ids you set
|
||||
(e.g. `ProgramScheduleItemWatermark.WatermarkId`); their reference navs are null, and any mapper that
|
||||
dereferences one unguarded throws an NRE that surfaces as a 500. Reload with the read-side includes
|
||||
before mapping. Exemplars: `ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler` reload
|
||||
via `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` (the one include chain shared with
|
||||
`GetProgramScheduleItemsHandler`). Also beware LanguageExt `Map` is **lazy** — returning
|
||||
`items.Map(ProjectToViewModel)` defers the projection, so a test that only checks `.IsRight` won't catch
|
||||
the NRE; the controller's `.ToList()`/serialization does (regression: `ScheduleItemWriteProjectionTests`).
|
||||
GET handlers that feed an ordered list must also `.OrderBy(i => i.Index)` — id order is not index order.
|
||||
|
||||
## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows)
|
||||
|
||||
`GET /api/blocks` and `GET /api/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in
|
||||
|
||||
+18
-13
@@ -62,15 +62,15 @@ redirect).
|
||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
|
||||
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
|
||||
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
||||
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
|
||||
> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 |
|
||||
> | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
|
||||
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
|
||||
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
||||
> | Schedules editors | **disproven — moved to Section 3** | **#207** |
|
||||
> | Schedules editors | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** |
|
||||
> | Media sources | **disproven — moved to Section 3** | **#202** |
|
||||
>
|
||||
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 is SHOULD-FIX (#213 closed 2026-07-11).
|
||||
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11.
|
||||
|
||||
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table
|
||||
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet.
|
||||
@@ -84,7 +84,7 @@ been added to the redirect map yet.
|
||||
|
||||
| Blazor route | Blazor file | SPA route | Notes |
|
||||
|---|---|---|---|
|
||||
| `/channels/{Id:int?}` | `ChannelEditor.razor` | `/app/edit-channel/{id}` | allowSubPaths |
|
||||
| `/channels/{Id:int?}` | `ChannelEditor.razor` | `/app/edit-channel/{id}` | allowSubPaths; external logo URL field (mutual exclusion with upload), enumerated language/credits-template/stream-selector pickers, and bare-channel create (`ChannelsScreen`'s "New blank channel") landed #212 |
|
||||
| `/channels/numbers` | `ChannelNumbers.razor` | `/app/channels` | merged into channels table |
|
||||
| `/search` | `Search.razor` | `/app/search` | |
|
||||
| `/system/logs` | `Logs.razor` | `/app/logs` | |
|
||||
@@ -167,20 +167,25 @@ connection parameters, path replacements, and library enable/disable have **no S
|
||||
write REST API** (`MediaSourcesController` is GET-only; the "Add Source" button is a hardcoded
|
||||
disabled stub). A fresh install could not connect any library without Blazor.
|
||||
|
||||
### Schedule editors — #207 (found by the #203 mutation-depth sweep, 2026-07-09)
|
||||
### Schedule editors — #207 DONE (2026-07-11)
|
||||
|
||||
| Blazor route | Blazor file | Blocking issue |
|
||||
|---|---|---|
|
||||
| `/schedules/{Id:int}`, `/schedules/add`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | **#207** |
|
||||
| Blazor route | Blazor file | SPA route | Status |
|
||||
|---|---|---|---|
|
||||
| `/schedules/{Id:int}`, `/schedules/add`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | `/app/schedules` (`SchedulesScreen`) | **PARITY-OK** |
|
||||
|
||||
Previously (wrongly) listed in Section 2 as "merged into schedules screen": the SPA schedules
|
||||
screen is a near read-only viewer — no schedule create/edit/delete, every per-item inspector
|
||||
control hard-coded disabled (~35 Blazor fields unchangeable), add-item is defaults-only. The
|
||||
REST endpoints exist and are unused; only item delete/reorder and the schedule switcher work.
|
||||
Was (correctly, until #207) a near read-only viewer — no schedule create/edit/delete, every per-item
|
||||
inspector control hard-coded disabled (~35 Blazor fields unchangeable), add-item defaults-only.
|
||||
**Rebuilt in #207** (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`): full schedule
|
||||
CRUD (create via TopBar/in-screen, edit properties, delete) + full item mutation — every Blazor field,
|
||||
option list, enable-gate and forced-reset, encoded in the pure `itemRules.ts` (exhaustively unit-tested)
|
||||
and applied in the inspector. Uses a **draft / explicit-Save** model over the single destructive
|
||||
`PUT .../items` replace (not the old instant-persist), with Discard + a dirty navigation guard — see
|
||||
`decisions.md` 2026-07-11 (a)/(b)/(c) and `spa-conventions.md` §8. The capability matrix (issue #207)
|
||||
maps each Blazor field → SPA control → request key → test.
|
||||
|
||||
### Remaining mutation-depth gaps inside Section 2 rows
|
||||
|
||||
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
|
||||
Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor),
|
||||
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
|
||||
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
|
||||
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
|
||||
|
||||
+85
-1
@@ -262,6 +262,26 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t
|
||||
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
|
||||
command exists server-side. Issues #208/#209.
|
||||
|
||||
## 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
|
||||
|
||||
`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` /
|
||||
`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer
|
||||
`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
|
||||
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
|
||||
The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`,
|
||||
`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`,
|
||||
`discardToFillAttempts` — mapped by pattern-matching the concrete VM in
|
||||
`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are
|
||||
named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST
|
||||
(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries
|
||||
picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`,
|
||||
`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` /
|
||||
`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`.
|
||||
`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and
|
||||
Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is
|
||||
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
|
||||
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
|
||||
|
||||
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
|
||||
|
||||
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
|
||||
@@ -299,7 +319,71 @@ is "no mutation during a build", matching Blazor's edit-disable.
|
||||
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
|
||||
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
|
||||
|
||||
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
|
||||
## 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
|
||||
|
||||
The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`,
|
||||
issue #207) rebuilds the schedule-item lineup to full mutation parity with the legacy Blazor editor.
|
||||
Three deliberate decisions:
|
||||
|
||||
**(a) Draft model with one explicit Save, replacing instant-persist.** All add/edit/copy/remove/
|
||||
reorder mutate a **local draft list only**; a single **Save** issues one
|
||||
`PUT /api/schedules/{id}/items` (the replace endpoint). This is intentional because that PUT is
|
||||
**destructive server-side** — it deletes and recreates every item row (new ids) and triggers playout
|
||||
rebuilds — so batching edits into one flush (vs. the old per-action POST/DELETE/PUT) minimizes churn
|
||||
and gives the user a Discard/dirty affordance. On 422/network error the draft is kept and the error
|
||||
surfaced; on success the draft is replaced with the server response. A **Discard** action and a dirty
|
||||
guard (native `confirm()` on schedule-switch + in-app nav via `navigationGuard.ts`, plus a
|
||||
`beforeunload` listener) protect the draft. See `docs/spa-conventions.md` §8. This retires the old
|
||||
inline `ScheduleScreen` in `App.tsx` and its instant-persist add/delete/reorder tests.
|
||||
|
||||
**(b) Copy item deep-copies ALL source references, including multi/smart/rerun collections.** Blazor's
|
||||
`CopyItem` omitted the multi-collection / smart-collection / rerun-collection references when
|
||||
duplicating an item (copying only the plain collection/media-item/playlist refs) — a latent bug. The
|
||||
SPA's `copyDraftItem` (`web/src/schedules/itemRules.ts`) copies every source field + display name, so
|
||||
copying a MultiCollection/SmartCollection/Rerun item preserves its source. Deliberate deviation
|
||||
fixing the Blazor omission.
|
||||
|
||||
**(c) The shuffled-schedule GET normalization (`EnforceProperties`) is preserved lossiness, matching
|
||||
Blazor.** When a schedule has `ShuffleScheduleItems`, `GET .../items` still rewrites startType→Dynamic,
|
||||
Flood→One, and Playlist/Rerun playbackOrder→None (and zeroes discardToFillAttempts for non-random
|
||||
Duration items). The SPA does **not** fight this — it hides the Fixed start type and Flood playout
|
||||
mode from the option lists (and disables the reorder arrows) for shuffled schedules, mirroring Blazor,
|
||||
so a GET→edit→PUT round-trip stays consistent with the server's read-side normalization. Issue #207.
|
||||
|
||||
## 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)
|
||||
|
||||
Two Blazor-parity decisions closing the channel-editor gaps (`ChannelEditor.razor` +
|
||||
`ChannelEditViewModel`):
|
||||
|
||||
1. **Bare-channel create lives on the channels list, not a form-first route.** Blazor's
|
||||
`/channels` (no `Id`) is a full add form; the SPA instead adds a "New blank channel" action next
|
||||
to "Add Channel" on `ChannelsScreen` (`web/src/App.tsx`) that POSTs `CreateChannelRequest` with
|
||||
Blazor's computed add-mode defaults (`(max existing int-parsed channel number) + 1`, `name: "New
|
||||
Channel"`, `group: "ErsatzTV"`, `ffmpegProfileId` = `GET /api/settings/ffmpeg`'s
|
||||
`defaultFFmpegProfileId`, `streamingMode: "TransportStreamHybrid"`, `isEnabled`/`showInEpg: true`,
|
||||
every other field at its C# `default(T)` — see `ChannelEditor.razor`'s `else` branch for the
|
||||
source of truth) directly, then navigates to `/app/edit-channel/{id}` for the rest of the fields.
|
||||
This is a deliberate equivalent, not a parity gap: it reuses the existing full editor instead of
|
||||
duplicating its ~20 fields into a second form. "Add Channel" (`/app/new-channel`, the
|
||||
library-to-lineup `ChannelBuilder` flow) is unrelated and untouched.
|
||||
2. **External logo URL wins over an uploaded logo, mirroring
|
||||
`ChannelEditViewModel.ToUpdate/ToCreate`.** The channel's logo is `ArtworkContentTypeModel {
|
||||
path, contentType, isExternalUrl }`; on hydration, `isExternalUrl: true` populates a separate
|
||||
"External logo URL" field and the uploaded-logo draft state is treated as empty (`EMPTY_LOGO =
|
||||
{ path: '', contentType: '' }` in `ChannelEditScreen.tsx`) so the two never disagree. On submit,
|
||||
a non-blank URL always wins: `logo: { path: url, contentType: '' }`, exactly matching
|
||||
`ExternalLogoUrl`'s precedence in the C# view model. Uploading a file clears the URL field (the
|
||||
last-set field wins, Blazor parity via `UploadLogo`'s `_model.ExternalLogoUrl = null`). The URL
|
||||
is validated as http(s) client-side before save is enabled (Blazor has no equivalent validation;
|
||||
added because the field is free text with no server-side format check surfaced to the SPA).
|
||||
|
||||
Also landed with #212: `preferredAudioLanguageCode`/`preferredSubtitleLanguageCode`,
|
||||
`musicVideoCreditsTemplate`, and `streamSelector` moved from free-text `Input`s to `Select`s fed by
|
||||
`GET /api/languages` / `/api/channels/music-video-credits-templates` /
|
||||
`/api/channels/stream-selectors`. Each keeps the channel's currently-stored value selectable even if
|
||||
it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScreen.tsx`) so loading
|
||||
an existing channel never silently changes the value out from under an unmanaged language code or a
|
||||
template/selector file removed from disk since save.## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
|
||||
|
||||
With multiple sessions/agents working the repo in parallel, the old protocol — every session
|
||||
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
130 endpoints, 204 operations.
|
||||
133 endpoints, 207 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
| POST | `/api/channels/bulk/group` | | Move channels to a group |
|
||||
| POST | `/api/channels/bulk/renumber` | | Renumber channels |
|
||||
| POST | `/api/channels/from-lineup` | CreateChannelFromLineup | Create a channel from a library lineup |
|
||||
| GET | `/api/channels/music-video-credits-templates` | GetMusicVideoCreditsTemplates | Get available music video credits template names |
|
||||
| GET | `/api/channels/state` | | Get channel runtime state |
|
||||
| GET | `/api/channels/stream-selectors` | GetChannelStreamSelectors | Get available channel stream selector names |
|
||||
| POST | `/api/channels/{channelNumber}/playout/reset` | | Reset channel playout |
|
||||
| DELETE | `/api/channels/{id}` | | Delete a channel |
|
||||
| GET | `/api/channels/{id}` | GetChannelById | Get a channel by id |
|
||||
@@ -147,6 +149,12 @@
|
||||
| GET | `/api/images/folders` | GetImageFolders | List image library folders |
|
||||
| PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration |
|
||||
|
||||
## Languages
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/languages` | GetLanguages | Get all available language codes |
|
||||
|
||||
## Libraries
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|
||||
+19
-1
@@ -201,7 +201,25 @@ Also run `npm run check:api` if you touched anything OpenAPI-relevant (see `api-
|
||||
— it regenerates `src/api/generated/v1.d.ts` and fails the build if it's out of sync with what's
|
||||
committed.
|
||||
|
||||
## 8. Review checklist — temporal semantics
|
||||
## 8. Unsaved-changes navigation guard
|
||||
|
||||
Screens with a **draft / explicit-Save** model (edits accumulate locally, one Save flushes them —
|
||||
e.g. the schedules editor, `web/src/screens/SchedulesScreen.tsx`) must guard against losing the
|
||||
draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
|
||||
|
||||
- The screen **registers a guard** on mount: `registerNavigationGuard(() => !dirtyRef.current ||
|
||||
window.confirm(...))`, returning the unregister fn from the mount `useEffect` for cleanup. Only one
|
||||
guard is active at a time (the mounted screen); a stale unregister only clears its own guard.
|
||||
- `App.tsx`'s `navigate` handler calls `canLeaveCurrentScreen()` before `pushState` — a `false`
|
||||
return aborts the in-app sidebar/nav click. This covers React-router-less in-app navigation only.
|
||||
- The screen **also** guards the three paths the module can't see — a **schedule switch** and any
|
||||
in-screen action that would replace the draft use their own `window.confirm(...)`, and a
|
||||
`beforeunload` listener (installed while `dirty`) covers full-page unloads (reload / close tab /
|
||||
external link).
|
||||
|
||||
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
|
||||
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
|
||||
## 9. Review checklist — temporal semantics
|
||||
|
||||
- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
|
||||
unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer
|
||||
|
||||
+196
-379
@@ -52,7 +52,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
|
||||
expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
|
||||
expect(window.location.pathname).toBe('/app/schedules');
|
||||
});
|
||||
|
||||
@@ -742,6 +742,113 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
{
|
||||
ffmpegProfile: 'HLS Direct',
|
||||
group: 'Kids',
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
language: 'en',
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
showInEpg: true,
|
||||
sortNumber: 5.1,
|
||||
streamingMode: 'HLS Direct'
|
||||
},
|
||||
{
|
||||
ffmpegProfile: 'MPEG-TS',
|
||||
group: 'News',
|
||||
id: 2,
|
||||
isEnabled: true,
|
||||
language: 'fr',
|
||||
name: 'News 24',
|
||||
number: '24',
|
||||
showInEpg: false,
|
||||
sortNumber: 24,
|
||||
streamingMode: 'MPEG-TS'
|
||||
}
|
||||
],
|
||||
createChannelResponse: { id: 42 },
|
||||
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 7 })
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
|
||||
);
|
||||
|
||||
const body = requestBodyFor('/api/channels');
|
||||
expect(body).toMatchObject({
|
||||
categories: null,
|
||||
fallbackFillerId: null,
|
||||
ffmpegProfileId: 7,
|
||||
group: 'ErsatzTV',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
logo: { path: null, contentType: null },
|
||||
mirrorSourceChannelId: null,
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
name: 'New Channel',
|
||||
number: '25',
|
||||
playoutMode: 'Continuous',
|
||||
playoutOffset: null,
|
||||
playoutSource: 'Generated',
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
showInEpg: true,
|
||||
slugSeconds: null,
|
||||
songVideoMode: 'Default',
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
streamSelector: null,
|
||||
streamSelectorMode: 'Default',
|
||||
subtitleMode: 'None',
|
||||
transcodeMode: 'OnDemand',
|
||||
watermarkId: null
|
||||
});
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42'));
|
||||
});
|
||||
|
||||
it('surfaces a 422 error from bare-channel create without navigating', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
{
|
||||
ffmpegProfile: 'HLS Direct',
|
||||
group: 'Kids',
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
language: 'en',
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
showInEpg: true,
|
||||
sortNumber: 5.1,
|
||||
streamingMode: 'HLS Direct'
|
||||
}
|
||||
],
|
||||
createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
|
||||
|
||||
expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/channels');
|
||||
});
|
||||
|
||||
it('clears the selection when the channel view filter changes', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
@@ -798,22 +905,10 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Schedule editor from live schedule APIs', async () => {
|
||||
it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => {
|
||||
mockDashboardApi({
|
||||
scheduleItems: [
|
||||
scheduleItem({ durationEstimate: '01:30:00', id: 11, name: 'Saturday Cartoons' }),
|
||||
scheduleItem({
|
||||
collection: { id: 6, name: 'Station IDs' },
|
||||
collectionType: 'Collection',
|
||||
durationEstimate: null,
|
||||
guideMode: 'Filler',
|
||||
id: 12,
|
||||
name: 'Station IDs',
|
||||
playoutMode: 'Flood'
|
||||
})
|
||||
],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })],
|
||||
scheduleItemsTotalDuration: '01:30:00'
|
||||
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -821,293 +916,37 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('01:30:00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getAllByDisplayValue('Prime Time Cartoons').length).toBeGreaterThan(0);
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object));
|
||||
expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object));
|
||||
// the flat editor fetches its own pickers (issue #207)
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/languages', expect.any(Object));
|
||||
});
|
||||
|
||||
it('fetches the schedules screen data exactly once on mount', async () => {
|
||||
it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => {
|
||||
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
||||
expect(fetchCount('/api/schedules')).toBe(1);
|
||||
|
||||
// TopBar primary action for the schedules route reads "Add Schedule".
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Schedule' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByLabelText('Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Schedule editor error state when a picker endpoint fails', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([schedule({ id: 5, name: 'Prime Time Cartoons' })]));
|
||||
}
|
||||
|
||||
if (path === '/api/schedules/5/items') {
|
||||
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
|
||||
}
|
||||
|
||||
if (path === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
detail: 'Collections service is unavailable',
|
||||
status: 500,
|
||||
title: 'Internal error'
|
||||
}, 500));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Collections service is unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Schedule editor loading state', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Loading schedules')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Schedule editor API errors and retries 404 parent handling', async () => {
|
||||
mockDashboardApi({
|
||||
scheduleItemFailuresBeforeSuccess: 1,
|
||||
scheduleItemFailure: {
|
||||
detail: 'Schedule 5 was not found',
|
||||
status: 404,
|
||||
title: 'Not found'
|
||||
},
|
||||
schedules: [schedule({ id: 5, name: 'Missing Schedule' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Schedule 5 was not found')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(fetchCount('/api/schedules/5/items')).toBe(2);
|
||||
});
|
||||
|
||||
it('shows an empty Schedule editor state when no schedule exists', async () => {
|
||||
it('shows the empty Schedules state when no schedule exists', async () => {
|
||||
mockDashboardApi({ schedules: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty Schedule editor lineup for a schedule with no items', async () => {
|
||||
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Empty Schedule' })] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reorders schedule items with keyboard controls and sends the exact replace-all body', async () => {
|
||||
const first = scheduleItem({ id: 11, name: 'Saturday Cartoons' });
|
||||
const second = scheduleItem({
|
||||
collection: { id: 6, name: 'Station IDs' },
|
||||
durationEstimate: null,
|
||||
guideMode: 'Filler',
|
||||
id: 12,
|
||||
name: 'Station IDs',
|
||||
playoutMode: 'Flood'
|
||||
});
|
||||
|
||||
mockDashboardApi({
|
||||
replaceScheduleItemsResponse: [second, first],
|
||||
scheduleItems: [first, second],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
const collectionsFetchesBeforeReorder = fetchCount('/api/collections');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
|
||||
body: JSON.stringify({ items: [scheduleItemRequest(second), scheduleItemRequest(first)] }),
|
||||
method: 'PUT'
|
||||
}));
|
||||
});
|
||||
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
|
||||
const rows = within(lineup).getAllByRole('listitem');
|
||||
expect(rows[0]).toHaveTextContent('Station IDs');
|
||||
expect(rows[1]).toHaveTextContent('Saturday Cartoons');
|
||||
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeReorder);
|
||||
});
|
||||
|
||||
it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => {
|
||||
mockDashboardApi({
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items': {
|
||||
detail: 'Schedule items are locked',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [
|
||||
scheduleItem({ id: 11, name: 'Saturday Cartoons' }),
|
||||
scheduleItem({ collection: { id: 6, name: 'Station IDs' }, id: 12, name: 'Station IDs' })
|
||||
],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
|
||||
|
||||
expect(await screen.findByText('Schedule items are locked')).toBeInTheDocument();
|
||||
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
|
||||
expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons');
|
||||
});
|
||||
|
||||
it('adds schedule items using the selected (non-first) collection', async () => {
|
||||
const added = scheduleItem({ collection: { id: 4, name: 'Nature Docs' }, id: 20, name: 'Nature Docs' });
|
||||
mockDashboardApi({
|
||||
addScheduleItemResponse: added,
|
||||
collections: [
|
||||
collection({ id: 3, name: 'Movie Mix' }),
|
||||
collection({ id: 4, name: 'Nature Docs' })
|
||||
],
|
||||
scheduleItems: [],
|
||||
scheduleItemsAfterAdd: [added],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
|
||||
const collectionsFetchesBeforeAdd = fetchCount('/api/collections');
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Collection for new item' }), {
|
||||
target: { value: '4' }
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
|
||||
|
||||
expect(await screen.findAllByText('Nature Docs')).not.toHaveLength(0);
|
||||
const expectedBody = scheduleItemRequest(added);
|
||||
expect(expectedBody.collectionId).toBe(4);
|
||||
expect(expectedBody.collectionType).toBe('Collection');
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
|
||||
body: JSON.stringify(expectedBody),
|
||||
method: 'POST'
|
||||
}));
|
||||
expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2);
|
||||
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeAdd);
|
||||
});
|
||||
|
||||
it('shows ProblemDetails when adding a schedule item fails', async () => {
|
||||
mockDashboardApi({
|
||||
collections: [collection({ id: 3, name: 'Movie Mix' })],
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items': {
|
||||
detail: 'Collection is required',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
|
||||
|
||||
expect(await screen.findByText('Collection is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes schedule items and refetches after success', async () => {
|
||||
mockDashboardApi({
|
||||
confirm: true,
|
||||
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
|
||||
scheduleItemsAfterDelete: [],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items/11', expect.objectContaining({
|
||||
method: 'DELETE'
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows ProblemDetails when deleting a schedule item fails', async () => {
|
||||
mockDashboardApi({
|
||||
confirm: true,
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items/11': {
|
||||
detail: 'Item is in use by an active playout',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
|
||||
|
||||
expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument();
|
||||
expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the Playouts monitor from live playout and channel state APIs', async () => {
|
||||
@@ -3420,16 +3259,6 @@ function schedule(overrides: Record<string, unknown> = {}): Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
function collection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
collectionType: 'Collection',
|
||||
id: 2,
|
||||
name: 'Saturday Cartoons',
|
||||
useCustomPlaybackOrder: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 31,
|
||||
@@ -3661,107 +3490,67 @@ function playoutItem(overrides: Record<string, unknown> = {}): Record<string, un
|
||||
}
|
||||
|
||||
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) };
|
||||
|
||||
// Flat ScheduleItemResponseModel shape (issue #126/#207).
|
||||
return {
|
||||
collection,
|
||||
collectionType: 'Collection',
|
||||
customTitle: null,
|
||||
discardToFillAttempts: null,
|
||||
durationEstimate: '00:25:00',
|
||||
fallbackFiller: null,
|
||||
fillWithGroupMode: 'None',
|
||||
fixedStartTimeBehavior: null,
|
||||
graphicsElements: [],
|
||||
guideMode: 'Normal',
|
||||
id: 11,
|
||||
index: 0,
|
||||
marathonBatchSize: null,
|
||||
startType: 'Dynamic',
|
||||
startTime: null,
|
||||
fixedStartTimeBehavior: null,
|
||||
playoutMode: 'One',
|
||||
collectionType: 'Collection',
|
||||
collectionId: 2,
|
||||
multiCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
rerunCollectionId: null,
|
||||
mediaItemId: null,
|
||||
playlistId: null,
|
||||
searchTitle: null,
|
||||
searchQuery: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
mediaItem: null,
|
||||
midRollFiller: null,
|
||||
marathonBatchSize: null,
|
||||
fillWithGroupMode: 'None',
|
||||
multipleMode: null,
|
||||
multipleCount: null,
|
||||
multipleMode: 'Count',
|
||||
multiCollection: null,
|
||||
name: collection.name,
|
||||
playbackOrder: 'Shuffle',
|
||||
playlist: null,
|
||||
playoutDuration: null,
|
||||
playoutMode: 'One',
|
||||
postRollFiller: null,
|
||||
tailMode: null,
|
||||
discardToFillAttempts: null,
|
||||
customTitle: null,
|
||||
guideMode: 'Normal',
|
||||
preRollFillerId: null,
|
||||
midRollFillerId: null,
|
||||
postRollFillerId: null,
|
||||
tailFillerId: null,
|
||||
fallbackFillerId: null,
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: [],
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
preRollFiller: null,
|
||||
rerunCollection: null,
|
||||
searchQuery: null,
|
||||
searchTitle: null,
|
||||
smartCollection: null,
|
||||
startTime: null,
|
||||
startType: 'Dynamic',
|
||||
subtitleMode: null,
|
||||
tailFiller: null,
|
||||
tailMode: 'None',
|
||||
watermarkIds: [],
|
||||
collectionName: 'Saturday Cartoons',
|
||||
multiCollectionName: null,
|
||||
smartCollectionName: null,
|
||||
rerunCollectionName: null,
|
||||
playlistName: null,
|
||||
playlistGroupId: null,
|
||||
mediaItemName: null,
|
||||
preRollFillerName: null,
|
||||
midRollFillerName: null,
|
||||
postRollFillerName: null,
|
||||
tailFillerName: null,
|
||||
fallbackFillerName: null,
|
||||
watermarks: [],
|
||||
graphicsElements: [],
|
||||
name: 'Saturday Cartoons',
|
||||
durationEstimate: '00:25:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItemRequest(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const collection = item.collection as { id?: number } | null | undefined;
|
||||
const multiCollection = item.multiCollection as { id?: number } | null | undefined;
|
||||
const smartCollection = item.smartCollection as { id?: number } | null | undefined;
|
||||
const rerunCollection = item.rerunCollection as { id?: number } | null | undefined;
|
||||
const mediaItem = item.mediaItem as { id?: number } | null | undefined;
|
||||
const playlist = item.playlist as { id?: number } | null | undefined;
|
||||
const watermarks = item.watermarks as Array<{ id: number }> | null | undefined;
|
||||
const graphicsElements = item.graphicsElements as Array<{ id: number }> | null | undefined;
|
||||
const fillerId = (value: unknown) => (value as { id?: number } | null | undefined)?.id ?? null;
|
||||
|
||||
return {
|
||||
collectionId: collection?.id ?? null,
|
||||
collectionType: item.collectionType ?? 'Collection',
|
||||
customTitle: item.customTitle ?? null,
|
||||
discardToFillAttempts: item.discardToFillAttempts ?? null,
|
||||
fallbackFillerId: fillerId(item.fallbackFiller),
|
||||
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
|
||||
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
|
||||
graphicsElementIds: graphicsElements?.map((element) => element.id) ?? [],
|
||||
guideMode: item.guideMode ?? 'Normal',
|
||||
marathonBatchSize: item.marathonBatchSize ?? null,
|
||||
marathonGroupBy: item.marathonGroupBy ?? 'None',
|
||||
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
|
||||
marathonShuffleItems: item.marathonShuffleItems ?? false,
|
||||
mediaItemId: mediaItem?.id ?? null,
|
||||
midRollFillerId: fillerId(item.midRollFiller),
|
||||
multipleCount: item.multipleCount ?? null,
|
||||
multipleMode: item.multipleMode ?? 'Count',
|
||||
multiCollectionId: multiCollection?.id ?? null,
|
||||
playbackOrder: item.playbackOrder ?? 'Shuffle',
|
||||
playlistId: playlist?.id ?? null,
|
||||
playoutDuration: item.playoutDuration ?? null,
|
||||
playoutMode: item.playoutMode ?? 'One',
|
||||
postRollFillerId: fillerId(item.postRollFiller),
|
||||
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
|
||||
preferredAudioTitle: item.preferredAudioTitle ?? null,
|
||||
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
|
||||
preRollFillerId: fillerId(item.preRollFiller),
|
||||
rerunCollectionId: rerunCollection?.id ?? null,
|
||||
searchQuery: item.searchQuery ?? null,
|
||||
searchTitle: item.searchTitle ?? null,
|
||||
smartCollectionId: smartCollection?.id ?? null,
|
||||
startTime: item.startTime ?? null,
|
||||
startType: item.startType ?? 'Dynamic',
|
||||
subtitleMode: item.subtitleMode ?? null,
|
||||
tailFillerId: fillerId(item.tailFiller),
|
||||
tailMode: item.tailMode ?? 'None',
|
||||
watermarkIds: watermarks?.map((watermark) => watermark.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockDashboardApi({
|
||||
addScheduleItemResponse = null,
|
||||
artworkUploadFailure = null,
|
||||
@@ -3770,6 +3559,8 @@ function mockDashboardApi({
|
||||
channels = [],
|
||||
channelStates = [],
|
||||
channelTemplates = [],
|
||||
createChannelFailure = null,
|
||||
createChannelResponse = null,
|
||||
createTemplateResponse = null,
|
||||
defaultChannelTemplate = null,
|
||||
playoutAlternateSchedules = [],
|
||||
@@ -3837,6 +3628,8 @@ function mockDashboardApi({
|
||||
channels?: unknown[];
|
||||
channelStates?: unknown[];
|
||||
channelTemplates?: unknown[];
|
||||
createChannelFailure?: { detail?: string; status?: number; title?: string } | null;
|
||||
createChannelResponse?: unknown;
|
||||
createTemplateResponse?: unknown;
|
||||
defaultChannelTemplate?: unknown;
|
||||
playoutAlternateSchedules?: unknown[];
|
||||
@@ -3923,6 +3716,13 @@ function mockDashboardApi({
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/channels' && (init?.method ?? 'GET') === 'POST') {
|
||||
if (createChannelFailure) {
|
||||
return Promise.resolve(jsonResponse(createChannelFailure, createChannelFailure.status ?? 422));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(createChannelResponse ?? { id: 99 }, 201));
|
||||
}
|
||||
|
||||
if (path === '/api/channels') {
|
||||
return Promise.resolve(jsonResponse(channels));
|
||||
}
|
||||
@@ -4040,7 +3840,7 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(multiCollections));
|
||||
}
|
||||
|
||||
if (path === '/api/filler-presets') {
|
||||
if (path.startsWith('/api/filler-presets')) {
|
||||
return Promise.resolve(jsonResponse(fillerPresets));
|
||||
}
|
||||
|
||||
@@ -4048,6 +3848,23 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(watermarks));
|
||||
}
|
||||
|
||||
// Schedule-editor pickers (issue #207): empty defaults are enough for the App-level route tests.
|
||||
if (path.startsWith('/api/rerun-collections')) {
|
||||
return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
|
||||
}
|
||||
|
||||
if (path === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/graphics-elements') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/languages') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/media-sources') {
|
||||
if (remainingMediaSourcesFailures > 0) {
|
||||
remainingMediaSourcesFailures -= 1;
|
||||
|
||||
+93
-650
@@ -3,13 +3,11 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Bell,
|
||||
Boxes,
|
||||
Palette,
|
||||
@@ -23,7 +21,6 @@ import {
|
||||
CircleHelp,
|
||||
Clock,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Crosshair,
|
||||
Film,
|
||||
FileImage,
|
||||
@@ -31,7 +28,6 @@ import {
|
||||
Folder,
|
||||
FolderInput,
|
||||
FolderTree,
|
||||
GripVertical,
|
||||
Hash,
|
||||
HardDrive,
|
||||
Info,
|
||||
@@ -55,12 +51,10 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Shuffle,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Stamp,
|
||||
Stethoscope,
|
||||
Timer,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Tv
|
||||
@@ -76,6 +70,8 @@ import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
|
||||
import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen';
|
||||
import { PlaylistsScreen } from './screens/PlaylistsScreen';
|
||||
import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen';
|
||||
import { SchedulesScreen } from './screens/SchedulesScreen';
|
||||
import { canLeaveCurrentScreen } from './navigationGuard';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
MovieDetailScreen,
|
||||
@@ -117,8 +113,7 @@ import {
|
||||
Spinner,
|
||||
Stat,
|
||||
StatusDot,
|
||||
Switch,
|
||||
Tabs
|
||||
Switch
|
||||
} from './components';
|
||||
import {
|
||||
useChannelsQuery,
|
||||
@@ -132,19 +127,17 @@ import {
|
||||
erasePlayoutItemsAndHistory,
|
||||
getPlayoutItemSchedulingContext,
|
||||
resetChannelPlayout,
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
getDecos,
|
||||
getFfmpegSettings,
|
||||
ApiError,
|
||||
messageFromError,
|
||||
addScheduleItem,
|
||||
deleteScheduleItem,
|
||||
getSchedules,
|
||||
resetAllPlayouts,
|
||||
replaceScheduleItems,
|
||||
updatePlayoutDefaultDeco,
|
||||
updatePlayoutDetails,
|
||||
usePlayoutsScreenQuery,
|
||||
useScheduleScreenQuery,
|
||||
useDashboardHealthQuery,
|
||||
useDashboardQuery,
|
||||
useDashboardVersionQuery,
|
||||
@@ -156,23 +149,20 @@ import {
|
||||
type ChannelSummary,
|
||||
type ChannelGuideChannel,
|
||||
type ChannelGuideProgramme,
|
||||
type CreateChannelRequest,
|
||||
type CreatePlayoutRequest,
|
||||
type DashboardChannel,
|
||||
type DecoListItem,
|
||||
type SchedulePickerData,
|
||||
type DashboardChannelState,
|
||||
type DashboardHealthQueryState,
|
||||
type LibraryScanStatus,
|
||||
type MediaSource,
|
||||
type MediaSourceLibrary,
|
||||
type MediaCollection,
|
||||
type PlayoutDetail,
|
||||
type PlayoutScheduleKind,
|
||||
type ProgramSchedule,
|
||||
type ProgramScheduleItem,
|
||||
type PlayoutItem,
|
||||
type PlayoutSummary,
|
||||
type ScheduleItemRequest
|
||||
} from './api';
|
||||
import {
|
||||
applyDesignSystemTheme,
|
||||
@@ -906,7 +896,18 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
|
||||
<span className="ctv-avatar">TB</span>
|
||||
</div>
|
||||
|
||||
<Button startIcon={<Plus aria-hidden="true" size={15} />}>{primaryAction}</Button>
|
||||
<Button
|
||||
startIcon={<Plus aria-hidden="true" size={15} />}
|
||||
onClick={() => {
|
||||
// Screens opt in to the TopBar primary action by listening for this event keyed on their
|
||||
// route id (e.g. SchedulesScreen opens its create dialog on 'schedules').
|
||||
if (route) {
|
||||
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: route.id }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{primaryAction}
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1268,6 +1269,7 @@ function ChannelsScreen() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [creatingBlank, setCreatingBlank] = useState(false);
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <ChannelsLoadingState />;
|
||||
@@ -1398,6 +1400,63 @@ function ChannelsScreen() {
|
||||
void refreshAfterMutation(() => deleteChannel(channel.id));
|
||||
};
|
||||
|
||||
// Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these
|
||||
// defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel",
|
||||
// which is the library-to-lineup ChannelBuilder flow (untouched here).
|
||||
const createBlankChannel = async () => {
|
||||
if (creatingBlank) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutationError(null);
|
||||
setCreatingBlank(true);
|
||||
|
||||
try {
|
||||
const ffmpegSettings = await getFfmpegSettings();
|
||||
const maxNumber = channels.reduce((max, channel) => {
|
||||
const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10);
|
||||
return Number.isFinite(parsed) ? Math.max(max, parsed) : max;
|
||||
}, 0);
|
||||
|
||||
const body: CreateChannelRequest = {
|
||||
categories: null,
|
||||
fallbackFillerId: null,
|
||||
ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId,
|
||||
group: 'ErsatzTV',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
logo: { path: null, contentType: null },
|
||||
mirrorSourceChannelId: null,
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
name: 'New Channel',
|
||||
number: String(maxNumber + 1),
|
||||
playoutMode: 'Continuous',
|
||||
playoutOffset: null,
|
||||
playoutSource: 'Generated',
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
showInEpg: true,
|
||||
slugSeconds: null,
|
||||
songVideoMode: 'Default',
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
streamSelector: null,
|
||||
streamSelectorMode: 'Default',
|
||||
subtitleMode: 'None',
|
||||
transcodeMode: 'OnDemand',
|
||||
watermarkId: null
|
||||
};
|
||||
|
||||
const created = await createChannel(body);
|
||||
navigateToPath(`/app/edit-channel/${created.id}`);
|
||||
} catch (error: unknown) {
|
||||
setMutationError(messageFromError(error));
|
||||
} finally {
|
||||
setCreatingBlank(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (channels.length === 0) {
|
||||
return <ChannelsEmptyState />;
|
||||
}
|
||||
@@ -1424,6 +1483,16 @@ function ChannelsScreen() {
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
|
||||
<Button
|
||||
disabled={creatingBlank}
|
||||
loading={creatingBlank}
|
||||
onClick={() => void createBlankChannel()}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
New blank channel
|
||||
</Button>
|
||||
<Button size="sm" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => navigateToPath('/app/new-channel')}>Add Channel</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1595,630 +1664,10 @@ function ChannelTableRow({
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides';
|
||||
|
||||
function SchedulesLoadingState() {
|
||||
return (
|
||||
<Card>
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner size={20} tone="accent" />
|
||||
<span>Loading schedules</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Schedules unavailable</h2>}
|
||||
subtitle="Live API request failed"
|
||||
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-dashboard-error">
|
||||
<span>API request failed</span>
|
||||
<strong>{error}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesEmptyState() {
|
||||
return (
|
||||
<Card title={<h2>No schedules</h2>} subtitle="The API returned an empty schedule list.">
|
||||
<div className="ctv-dashboard-empty">No schedules returned</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleScreen() {
|
||||
const query = useScheduleScreenQuery();
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
|
||||
const [dragItemId, setDragItemId] = useState<number | null>(null);
|
||||
const [overItemId, setOverItemId] = useState<number | null>(null);
|
||||
const [inspectorTab, setInspectorTab] = useState<ScheduleInspectorTab>('content');
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const mutatingRef = useRef(false);
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <SchedulesLoadingState />;
|
||||
}
|
||||
|
||||
if (query.status === 'error') {
|
||||
return <SchedulesErrorState error={query.error} refresh={query.refresh} />;
|
||||
}
|
||||
|
||||
const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data;
|
||||
const { itemsLoading, setActiveSchedule, setItems } = query;
|
||||
|
||||
if (!activeSchedule) {
|
||||
return <SchedulesEmptyState />;
|
||||
}
|
||||
|
||||
const orderedItems = sortedScheduleItems(items);
|
||||
const selectedItem = orderedItems.find((item) => item.id === selectedItemId) ?? orderedItems[0] ?? null;
|
||||
const effectiveSelectedItemId = selectedItem?.id ?? null;
|
||||
const selectedIndex = selectedItem ? orderedItems.findIndex((item) => item.id === selectedItem.id) : -1;
|
||||
const collectionOptions = sortedCollections(pickers.collections);
|
||||
const effectiveCollectionId = collectionOptions.some((collection) => collection.id === selectedCollectionId)
|
||||
? selectedCollectionId
|
||||
: collectionOptions[0]?.id ?? null;
|
||||
|
||||
const setMutatingState = (value: boolean) => {
|
||||
mutatingRef.current = value;
|
||||
setMutating(value);
|
||||
};
|
||||
|
||||
const refetchItemsAfterMutation = async (operation: () => Promise<void>) => {
|
||||
setMutationError(null);
|
||||
setMutatingState(true);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
setActiveSchedule(activeSchedule.id);
|
||||
} catch (error: unknown) {
|
||||
setMutationError(messageFromError(error));
|
||||
} finally {
|
||||
setMutatingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const persistOrder = async (nextItems: ProgramScheduleItem[]) => {
|
||||
const previousSelectedId = effectiveSelectedItemId;
|
||||
setSelectedItemId((current) => current ?? previousSelectedId ?? nextItems[0]?.id ?? null);
|
||||
|
||||
try {
|
||||
setMutationError(null);
|
||||
setMutatingState(true);
|
||||
const result = await replaceScheduleItems(activeSchedule.id, {
|
||||
items: nextItems.map(scheduleItemToRequest)
|
||||
});
|
||||
setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null);
|
||||
setItems(result);
|
||||
} catch (error: unknown) {
|
||||
setSelectedItemId(previousSelectedId);
|
||||
setMutationError(messageFromError(error));
|
||||
} finally {
|
||||
setMutatingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveItem = (itemId: number, direction: -1 | 1) => {
|
||||
if (mutatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = orderedItems.findIndex((item) => item.id === itemId);
|
||||
const nextIndex = index + direction;
|
||||
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= orderedItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = [...orderedItems];
|
||||
const [moved] = nextItems.splice(index, 1);
|
||||
nextItems.splice(nextIndex, 0, moved);
|
||||
void persistOrder(nextItems);
|
||||
};
|
||||
|
||||
const dropItem = (targetId: number) => {
|
||||
if (dragItemId == null || dragItemId === targetId || mutatingRef.current) {
|
||||
setDragItemId(null);
|
||||
setOverItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const from = orderedItems.findIndex((item) => item.id === dragItemId);
|
||||
const to = orderedItems.findIndex((item) => item.id === targetId);
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
const nextItems = [...orderedItems];
|
||||
const [moved] = nextItems.splice(from, 1);
|
||||
nextItems.splice(to, 0, moved);
|
||||
void persistOrder(nextItems);
|
||||
}
|
||||
|
||||
setDragItemId(null);
|
||||
setOverItemId(null);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const collection = collectionOptions.find((candidate) => candidate.id === effectiveCollectionId);
|
||||
|
||||
if (!collection) {
|
||||
setMutationError('A collection is required before adding a schedule item');
|
||||
return;
|
||||
}
|
||||
|
||||
void refetchItemsAfterMutation(async () => {
|
||||
const added = await addScheduleItem(activeSchedule.id, newScheduleItemRequest(collection));
|
||||
setSelectedItemId(added.id ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteItem = (item: ProgramScheduleItem) => {
|
||||
const itemId = item.id;
|
||||
|
||||
if (itemId == null || !window.confirm(`Remove ${scheduleItemName(item)}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refetchItemsAfterMutation(async () => {
|
||||
await deleteScheduleItem(activeSchedule.id, itemId);
|
||||
setSelectedItemId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-screen">
|
||||
<section className="ctv-schedule-header">
|
||||
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
|
||||
<div>
|
||||
<h2>{activeSchedule.name ?? 'Unnamed schedule'}</h2>
|
||||
<p>
|
||||
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs <code>{totalDurationEstimate ?? 'unknown'}</code>
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
disabled={mutating}
|
||||
label="Active schedule"
|
||||
onChange={(event) => setActiveSchedule(Number(event.target.value))}
|
||||
options={scheduleOptions(schedules)}
|
||||
size="sm"
|
||||
value={`${activeSchedule.id}`}
|
||||
/>
|
||||
<Button disabled startIcon={<Play aria-hidden="true" size={15} />} variant="secondary">Preview playout</Button>
|
||||
</section>
|
||||
|
||||
{mutationError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{mutationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-schedule-grid">
|
||||
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
|
||||
<div className="ctv-schedule-panel-head">
|
||||
<span>Lineup · drag to reorder</span>
|
||||
{itemsLoading && <Spinner size={13} tone="muted" />}
|
||||
<Select
|
||||
disabled={mutating || collectionOptions.length === 0}
|
||||
label="Collection for new item"
|
||||
onChange={(event) => setSelectedCollectionId(Number(event.target.value))}
|
||||
options={pickerOptions(collectionOptions)}
|
||||
size="sm"
|
||||
value={`${effectiveCollectionId ?? ''}`}
|
||||
/>
|
||||
<Button disabled={mutating} onClick={addItem} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add item</Button>
|
||||
</div>
|
||||
|
||||
{orderedItems.length === 0 ? (
|
||||
<div className="ctv-schedule-empty">No schedule items</div>
|
||||
) : (
|
||||
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
|
||||
{orderedItems.map((item, index) => (
|
||||
<ScheduleItemBlock
|
||||
active={item.id === selectedItem?.id}
|
||||
dragging={item.id === dragItemId}
|
||||
isFirst={index === 0}
|
||||
isLast={index === orderedItems.length - 1}
|
||||
isOver={item.id === overItemId && dragItemId != null && dragItemId !== item.id}
|
||||
item={item}
|
||||
key={item.id ?? index}
|
||||
mutating={mutating}
|
||||
onDelete={deleteItem}
|
||||
onDrop={dropItem}
|
||||
onMove={moveItem}
|
||||
onOver={setOverItemId}
|
||||
onSelect={setSelectedItemId}
|
||||
onStartDrag={setDragItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ScheduleInspector
|
||||
item={selectedItem}
|
||||
itemIndex={selectedIndex}
|
||||
mutating={mutating}
|
||||
onDelete={deleteItem}
|
||||
pickers={pickers}
|
||||
schedule={activeSchedule}
|
||||
tab={inspectorTab}
|
||||
onTabChange={(value) => setInspectorTab(value as ScheduleInspectorTab)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleItemBlock({
|
||||
active,
|
||||
dragging,
|
||||
isFirst,
|
||||
isLast,
|
||||
isOver,
|
||||
item,
|
||||
mutating,
|
||||
onDelete,
|
||||
onDrop,
|
||||
onMove,
|
||||
onOver,
|
||||
onSelect,
|
||||
onStartDrag
|
||||
}: {
|
||||
active: boolean;
|
||||
dragging: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isOver: boolean;
|
||||
item: ProgramScheduleItem;
|
||||
mutating: boolean;
|
||||
onDelete: (item: ProgramScheduleItem) => void;
|
||||
onDrop: (targetId: number) => void;
|
||||
onMove: (itemId: number, direction: -1 | 1) => void;
|
||||
onOver: (itemId: number | null) => void;
|
||||
onSelect: (itemId: number | null) => void;
|
||||
onStartDrag: (itemId: number | null) => void;
|
||||
}) {
|
||||
const itemId = item.id ?? null;
|
||||
const name = scheduleItemName(item);
|
||||
const fill = scheduleFillDescriptor(item);
|
||||
const fixed = item.startType === 'Fixed';
|
||||
const filler = item.guideMode === 'Filler';
|
||||
|
||||
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
onOver(itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-lineup-row" role="listitem">
|
||||
<div className="ctv-schedule-rail" aria-hidden="true">
|
||||
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
|
||||
<code>{fixed ? formatScheduleTime(item.startTime) : 'flows'}</code>
|
||||
</div>
|
||||
<div
|
||||
aria-roledescription="draggable schedule item"
|
||||
className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}${dragging ? ' ctv-schedule-block-dragging' : ''}${isOver ? ' ctv-schedule-block-over' : ''}`}
|
||||
draggable={!mutating}
|
||||
onDragEnd={() => {
|
||||
onStartDrag(null);
|
||||
onOver(null);
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={() => onStartDrag(itemId)}
|
||||
onDrop={() => {
|
||||
if (itemId != null) {
|
||||
onDrop(itemId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-schedule-block-main"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onClick={() => onSelect(itemId)}
|
||||
>
|
||||
<GripVertical aria-hidden="true" size={16} />
|
||||
<ChannelLogo name={name} size={38} />
|
||||
<span className="ctv-schedule-block-title">
|
||||
<strong>{name}</strong>
|
||||
<small>{formatScheduleCollectionType(item.collectionType)}</small>
|
||||
</span>
|
||||
<span className="ctv-schedule-fill-chip">
|
||||
{fill.icon}
|
||||
<code>{fill.label}</code>
|
||||
</span>
|
||||
</button>
|
||||
<div className="ctv-schedule-block-meta">
|
||||
<span><ArrowDownWideNarrow aria-hidden="true" size={13} />{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</span>
|
||||
{filler && <Badge tone="neutral">Hidden from guide</Badge>}
|
||||
<span className="ctv-schedule-block-duration">{item.durationEstimate ?? 'unknown'}</span>
|
||||
</div>
|
||||
<div className="ctv-schedule-block-actions">
|
||||
<IconButton disabled={mutating || isFirst || itemId == null} onClick={() => itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}>
|
||||
<ChevronDown className="ctv-schedule-up-icon" aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={mutating || isLast || itemId == null} onClick={() => itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}>
|
||||
<ChevronDown aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={mutating || itemId == null} onClick={() => onDelete(item)} size="sm" title={`Remove ${name}`}>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleInspector({
|
||||
item,
|
||||
itemIndex,
|
||||
mutating,
|
||||
onDelete,
|
||||
onTabChange,
|
||||
pickers,
|
||||
schedule,
|
||||
tab
|
||||
}: {
|
||||
item: ProgramScheduleItem | null;
|
||||
itemIndex: number;
|
||||
mutating: boolean;
|
||||
onDelete: (item: ProgramScheduleItem) => void;
|
||||
onTabChange: (value: string) => void;
|
||||
pickers: SchedulePickerData;
|
||||
schedule: ProgramSchedule;
|
||||
tab: ScheduleInspectorTab;
|
||||
}) {
|
||||
if (!item) {
|
||||
return (
|
||||
<section className="ctv-schedule-inspector">
|
||||
<div className="ctv-schedule-empty">Select or add a schedule item</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const name = scheduleItemName(item);
|
||||
const fill = scheduleFillDescriptor(item);
|
||||
|
||||
return (
|
||||
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
|
||||
<div className="ctv-schedule-inspector-head">
|
||||
<ChannelLogo name={name} size={34} />
|
||||
<div>
|
||||
<strong>{name}</strong>
|
||||
<span>Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)}</span>
|
||||
</div>
|
||||
<IconButton disabled={mutating || item.id == null} onClick={() => onDelete(item)} title="Remove selected item">
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="ctv-schedule-inspector-badges">
|
||||
<Badge tone={item.startType === 'Fixed' ? 'accent' : 'neutral'} dot>{item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'}</Badge>
|
||||
<Badge tone="neutral">{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</Badge>
|
||||
<Badge tone="neutral">{fill.label}</Badge>
|
||||
</div>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={onTabChange}
|
||||
tabs={[
|
||||
{ value: 'content', label: 'Content' },
|
||||
{ value: 'playback', label: 'Playback' },
|
||||
{ value: 'filler', label: 'Filler' },
|
||||
{ value: 'overrides', label: 'Overrides' }
|
||||
]}
|
||||
/>
|
||||
<div className="ctv-schedule-inspector-body">
|
||||
{tab === 'content' && (
|
||||
<>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Start type" value={item.startType ?? 'Dynamic'} options={['Dynamic', 'Fixed']} />
|
||||
<Input disabled label="Start time" value={item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : ''} placeholder="dynamic" />
|
||||
</div>
|
||||
<Select disabled label="Collection type" value={item.collectionType ?? 'Collection'} options={[item.collectionType ?? 'Collection']} />
|
||||
<Select
|
||||
disabled
|
||||
label="Collection"
|
||||
value={`${collectionIdForItem(item) ?? ''}`}
|
||||
options={pickerOptions(pickers.collections)}
|
||||
/>
|
||||
<Input disabled label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" />
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Guide mode" value={item.guideMode ?? 'Normal'} options={['Normal', 'Filler']} />
|
||||
<Input disabled label="Schedule" value={schedule.name ?? 'Unnamed schedule'} />
|
||||
</div>
|
||||
<Checkbox disabled checked={schedule.keepMultiPartEpisodesTogether} label="Keep multi-part together" />
|
||||
</>
|
||||
)}
|
||||
{tab === 'playback' && (
|
||||
<>
|
||||
<Select disabled label="Playback order" value={item.playbackOrder ?? 'Shuffle'} options={[item.playbackOrder ?? 'Shuffle']} />
|
||||
<Select disabled label="Playout mode" value={item.playoutMode ?? 'One'} options={['Flood', 'One', 'Multiple', 'Duration']} />
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Input disabled label="Multiple count" value={item.multipleCount ?? item.count ?? ''} placeholder="n/a" />
|
||||
<Input disabled label="Duration estimate" value={item.durationEstimate ?? 'unknown'} />
|
||||
</div>
|
||||
<div className="ctv-schedule-note">
|
||||
{fill.icon}
|
||||
<span>{playbackExplanation(item)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tab === 'filler' && (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Pre-roll" value={item.preRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Mid-roll" value={item.midRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Post-roll" value={item.postRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Fallback" value={item.fallbackFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Tail" value={item.tailFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'overrides' && (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Watermarks" value={item.watermarks?.[0]?.name ?? 'Inherit'} options={['Inherit', ...namedPickerLabels(pickers.watermarks)]} />
|
||||
<Select disabled label="Subtitle mode" value={item.subtitleMode ?? 'Inherit'} options={['Inherit', 'None', 'Any', 'Forced', 'Default']} />
|
||||
<Input disabled label="Preferred audio" value={item.preferredAudioLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
|
||||
<Input disabled label="Preferred subtitle" value={item.preferredSubtitleLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortedScheduleItems(items: ProgramScheduleItem[]): ProgramScheduleItem[] {
|
||||
return [...items].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
|
||||
}
|
||||
|
||||
function scheduleItemToRequest(item: ProgramScheduleItem): ScheduleItemRequest {
|
||||
return {
|
||||
collectionId: item.collection?.id ?? null,
|
||||
collectionType: item.collectionType ?? 'Collection',
|
||||
customTitle: item.customTitle ?? null,
|
||||
discardToFillAttempts: item.discardToFillAttempts ?? null,
|
||||
fallbackFillerId: item.fallbackFiller?.id ?? null,
|
||||
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
|
||||
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
|
||||
graphicsElementIds: item.graphicsElements?.map((element) => element.id) ?? [],
|
||||
guideMode: item.guideMode ?? 'Normal',
|
||||
marathonBatchSize: item.marathonBatchSize ?? null,
|
||||
marathonGroupBy: item.marathonGroupBy ?? 'None',
|
||||
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
|
||||
marathonShuffleItems: item.marathonShuffleItems ?? false,
|
||||
mediaItemId: item.mediaItem?.mediaItemId ?? null,
|
||||
midRollFillerId: item.midRollFiller?.id ?? null,
|
||||
multipleCount: item.multipleCount ?? item.count ?? null,
|
||||
multipleMode: item.multipleMode ?? 'Count',
|
||||
multiCollectionId: item.multiCollection?.id ?? null,
|
||||
playbackOrder: item.playbackOrder ?? 'Shuffle',
|
||||
playlistId: item.playlist?.id ?? null,
|
||||
playoutDuration: item.playoutDuration ?? null,
|
||||
playoutMode: item.playoutMode ?? 'One',
|
||||
postRollFillerId: item.postRollFiller?.id ?? null,
|
||||
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
|
||||
preferredAudioTitle: item.preferredAudioTitle ?? null,
|
||||
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
|
||||
preRollFillerId: item.preRollFiller?.id ?? null,
|
||||
rerunCollectionId: item.rerunCollection?.id ?? null,
|
||||
searchQuery: item.searchQuery ?? null,
|
||||
searchTitle: item.searchTitle ?? null,
|
||||
smartCollectionId: item.smartCollection?.id ?? null,
|
||||
startTime: item.startTime ?? null,
|
||||
startType: item.startType ?? 'Dynamic',
|
||||
subtitleMode: item.subtitleMode ?? null,
|
||||
tailFillerId: item.tailFiller?.id ?? null,
|
||||
tailMode: item.tailMode ?? 'None',
|
||||
watermarkIds: item.watermarks?.map((watermark) => watermark.id) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function newScheduleItemRequest(collection: MediaCollection): ScheduleItemRequest {
|
||||
return {
|
||||
collectionId: collection.id,
|
||||
collectionType: collection.collectionType ?? 'Collection',
|
||||
customTitle: null,
|
||||
discardToFillAttempts: null,
|
||||
fallbackFillerId: null,
|
||||
fillWithGroupMode: 'None',
|
||||
fixedStartTimeBehavior: null,
|
||||
graphicsElementIds: [],
|
||||
guideMode: 'Normal',
|
||||
marathonBatchSize: null,
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
mediaItemId: null,
|
||||
midRollFillerId: null,
|
||||
multipleCount: null,
|
||||
multipleMode: 'Count',
|
||||
multiCollectionId: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
playlistId: null,
|
||||
playoutDuration: null,
|
||||
playoutMode: 'One',
|
||||
postRollFillerId: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
preRollFillerId: null,
|
||||
rerunCollectionId: null,
|
||||
searchQuery: null,
|
||||
searchTitle: null,
|
||||
smartCollectionId: null,
|
||||
startTime: null,
|
||||
startType: 'Dynamic',
|
||||
subtitleMode: null,
|
||||
tailFillerId: null,
|
||||
tailMode: 'None',
|
||||
watermarkIds: []
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItemName(item: ProgramScheduleItem): string {
|
||||
return item.name ?? item.collection?.name ?? item.multiCollection?.name ?? item.smartCollection?.name ?? item.searchTitle ?? item.searchQuery ?? 'Unnamed item';
|
||||
}
|
||||
|
||||
function collectionIdForItem(item: ProgramScheduleItem): number | null {
|
||||
return item.collection?.id ?? item.multiCollection?.id ?? item.smartCollection?.id ?? null;
|
||||
}
|
||||
|
||||
function sortedCollections(collections: MediaCollection[]): MediaCollection[] {
|
||||
return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
function scheduleOptions(schedules: ProgramSchedule[]): Array<{ label: string; value: string }> {
|
||||
return schedules.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }));
|
||||
}
|
||||
|
||||
function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } {
|
||||
if (item.playoutMode === 'Flood') {
|
||||
return { icon: <GripVertical aria-hidden="true" size={13} />, label: 'Fills to next' };
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Multiple') {
|
||||
return { icon: <Copy aria-hidden="true" size={13} />, label: `x${item.multipleCount ?? item.count ?? '?'}` };
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Duration') {
|
||||
return { icon: <Timer aria-hidden="true" size={13} />, label: item.durationEstimate ?? 'Duration' };
|
||||
}
|
||||
|
||||
return { icon: <Shuffle aria-hidden="true" size={13} />, label: '1 item' };
|
||||
}
|
||||
|
||||
function playbackExplanation(item: ProgramScheduleItem): string {
|
||||
if (item.playoutMode === 'Flood') {
|
||||
return 'Plays until the next fixed-start item.';
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Multiple') {
|
||||
return `Plays ${item.multipleCount ?? item.count ?? 'multiple'} items, then advances.`;
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Duration') {
|
||||
return 'Plays for a fixed duration.';
|
||||
}
|
||||
|
||||
return 'Plays exactly one item, then advances.';
|
||||
}
|
||||
|
||||
function formatScheduleTime(value: string | null | undefined): string {
|
||||
return value?.slice(0, 5) ?? 'dynamic';
|
||||
}
|
||||
|
||||
function formatScheduleEnum(value: string): string {
|
||||
return value.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
function formatScheduleCollectionType(value: string | undefined): string {
|
||||
return formatScheduleEnum(value ?? 'Collection');
|
||||
}
|
||||
|
||||
const EPG_SLOT_MINUTES = 30;
|
||||
const EPG_SLOT_WIDTH = 156;
|
||||
const EPG_RAIL_WIDTH = 196;
|
||||
@@ -3746,18 +3195,6 @@ function formatDailyRebuild(value: string | null | undefined): string {
|
||||
return value ? value.slice(0, 5) : 'manual';
|
||||
}
|
||||
|
||||
function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> {
|
||||
return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` }));
|
||||
}
|
||||
|
||||
function namedPickerLabels(items: unknown[]): string[] {
|
||||
return items.map((item) => (item as { name?: null | string }).name ?? 'Unnamed');
|
||||
}
|
||||
|
||||
function fillerOptions(items: unknown[]): string[] {
|
||||
return ['None', ...namedPickerLabels(items)];
|
||||
}
|
||||
|
||||
function PlaceholderScreen({ route }: { route: ScreenRoute }) {
|
||||
return (
|
||||
<div className="ctv-screen-stack">
|
||||
@@ -3902,7 +3339,7 @@ function ScreenContent({
|
||||
}
|
||||
|
||||
if (route.id === 'schedules') {
|
||||
return <ScheduleScreen />;
|
||||
return <SchedulesScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'blocks') {
|
||||
@@ -4030,6 +3467,12 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A screen with unsaved changes may veto navigation (e.g. the schedules editor prompts via
|
||||
// navigationGuard). See docs/spa-conventions.md §8.
|
||||
if (!canLeaveCurrentScreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', routeHref(nextRoute));
|
||||
setActiveRoute(nextRoute);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
|
||||
import {
|
||||
createChannel,
|
||||
getChannelById,
|
||||
getChannelStreamSelectors,
|
||||
getMusicVideoCreditsTemplates,
|
||||
updateChannel,
|
||||
type CreateChannelRequest,
|
||||
type UpdateChannelRequest
|
||||
} from './channels';
|
||||
|
||||
const sampleChannel = {
|
||||
id: 5,
|
||||
@@ -90,3 +98,69 @@ describe('updateChannel', () => {
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChannel', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs the request body to /api/channels and returns the created view model', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleChannel), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const body = { ...sampleChannel, id: undefined } as unknown as CreateChannelRequest;
|
||||
|
||||
await expect(createChannel(body)).resolves.toMatchObject({ id: 5 });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels');
|
||||
expect((init?.method ?? '').toUpperCase()).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMusicVideoCreditsTemplates', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs /api/channels/music-video-credits-templates', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(['default']), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getMusicVideoCreditsTemplates()).resolves.toEqual(['default']);
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/music-video-credits-templates');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannelStreamSelectors', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs /api/channels/stream-selectors', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(['selector.py']), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getChannelStreamSelectors()).resolves.toEqual(['selector.py']);
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/stream-selectors');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
|
||||
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
|
||||
export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
|
||||
|
||||
export interface ChannelsScreenData {
|
||||
channels: ChannelSummary[];
|
||||
@@ -46,6 +47,21 @@ export function updateChannel(channelId: number, body: UpdateChannelRequest): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export function createChannel(body: CreateChannelRequest): Promise<Channel> {
|
||||
return request<Channel>('/api/channels', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function getMusicVideoCreditsTemplates(): Promise<string[]> {
|
||||
return request<string[]>('/api/channels/music-video-credits-templates');
|
||||
}
|
||||
|
||||
export function getChannelStreamSelectors(): Promise<string[]> {
|
||||
return request<string[]>('/api/channels/stream-selectors');
|
||||
}
|
||||
|
||||
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
|
||||
const [channels, channelStates] = await Promise.all([
|
||||
getChannels(),
|
||||
|
||||
Vendored
+67
-119
@@ -685,24 +685,7 @@ export interface components {
|
||||
"FillerPresetResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"FillerPresetViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"fillerKind": components["schemas"]["FillerKind"];
|
||||
"fillerMode": components["schemas"]["FillerMode"];
|
||||
"duration": null | string;
|
||||
"count": null | number;
|
||||
"padToNearestMinute": null | number;
|
||||
"allowWatermarks": boolean;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"playlist": components["schemas"]["PlaylistViewModel"];
|
||||
"expression": null | string;
|
||||
"useChaptersAsMediaItems": boolean;
|
||||
};
|
||||
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
|
||||
"FilterMode": "HardwareIfPossible" | "Software";
|
||||
@@ -710,11 +693,6 @@ export interface components {
|
||||
"GraphicsElementResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"GraphicsElementViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"fileName": null | string;
|
||||
};
|
||||
"GuideMode": "Normal" | "Filler";
|
||||
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
|
||||
@@ -742,6 +720,10 @@ export interface components {
|
||||
"subfolderCount": number;
|
||||
"imageCount": number;
|
||||
"durationSeconds": null | number;
|
||||
};
|
||||
"LanguageCodeResponseModel": {
|
||||
"code": null | string;
|
||||
"englishName": null | string;
|
||||
};
|
||||
"LibraryBrowseItemResponseModel": {
|
||||
"id": number;
|
||||
@@ -890,33 +872,15 @@ export interface components {
|
||||
"name": string;
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
"items": Array<components["schemas"]["MultiCollectionItemResponseModel"]>;
|
||||
};
|
||||
"MultiCollectionSmartItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemViewModel"]>;
|
||||
"smartItems": null | Array<components["schemas"]["MultiCollectionSmartItemViewModel"]>;
|
||||
};
|
||||
"MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize";
|
||||
"NamedMediaItemViewModel": {
|
||||
"mediaItemId": number;
|
||||
"NamedIdResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"NormalizeLoudnessMode": "Off" | "LoudNorm";
|
||||
@@ -1000,12 +964,6 @@ export interface components {
|
||||
"playlistGroupId": number;
|
||||
"name": string;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlaylistViewModel": {
|
||||
"id": number;
|
||||
"playlistGroupId": number;
|
||||
"name": null | string;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlayoutAlternateScheduleItemRequest": {
|
||||
"id": number;
|
||||
@@ -1140,48 +1098,6 @@ export interface components {
|
||||
"status"?: null | number;
|
||||
"detail"?: null | string;
|
||||
"instance"?: null | string;
|
||||
};
|
||||
"ProgramScheduleItemsWithDurationViewModel": {
|
||||
"items": null | Array<components["schemas"]["ProgramScheduleItemViewModel"]>;
|
||||
"totalDurationEstimate": null | string;
|
||||
};
|
||||
"ProgramScheduleItemViewModel": {
|
||||
"id"?: number;
|
||||
"index"?: number;
|
||||
"startType"?: components["schemas"]["StartType"];
|
||||
"startTime"?: null | string;
|
||||
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
|
||||
"playoutMode"?: components["schemas"]["PlayoutMode"];
|
||||
"collectionType"?: components["schemas"]["CollectionType"];
|
||||
"collection"?: components["schemas"]["MediaCollectionViewModel"];
|
||||
"multiCollection"?: components["schemas"]["MultiCollectionViewModel"];
|
||||
"smartCollection"?: components["schemas"]["SmartCollectionViewModel"];
|
||||
"rerunCollection"?: components["schemas"]["RerunCollectionViewModel"];
|
||||
"playlist"?: components["schemas"]["PlaylistViewModel"];
|
||||
"mediaItem"?: components["schemas"]["NamedMediaItemViewModel"];
|
||||
"searchTitle"?: null | string;
|
||||
"searchQuery"?: null | string;
|
||||
"playbackOrder"?: components["schemas"]["PlaybackOrder"];
|
||||
"marathonGroupBy"?: components["schemas"]["MarathonGroupBy"];
|
||||
"marathonShuffleGroups"?: boolean;
|
||||
"marathonShuffleItems"?: boolean;
|
||||
"marathonBatchSize"?: null | number;
|
||||
"fillWithGroupMode"?: components["schemas"]["FillWithGroupMode"];
|
||||
"customTitle"?: null | string;
|
||||
"guideMode"?: components["schemas"]["GuideMode"];
|
||||
"preRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"midRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"postRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"tailFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"fallbackFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"watermarks"?: null | Array<components["schemas"]["WatermarkViewModel"]>;
|
||||
"graphicsElements"?: null | Array<components["schemas"]["GraphicsElementViewModel"]>;
|
||||
"preferredAudioLanguageCode"?: null | string;
|
||||
"preferredAudioTitle"?: null | string;
|
||||
"preferredSubtitleLanguageCode"?: null | string;
|
||||
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
|
||||
"durationEstimate"?: null | string;
|
||||
"name"?: null | string;
|
||||
};
|
||||
"ProgramScheduleViewModel": {
|
||||
"id": number;
|
||||
@@ -1251,17 +1167,6 @@ export interface components {
|
||||
"selectedName": null | string;
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"RerunCollectionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
"multiCollection": components["schemas"]["MultiCollectionViewModel"];
|
||||
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
|
||||
"mediaItem": components["schemas"]["NamedMediaItemViewModel"];
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"ResolutionResponseModel": {
|
||||
"id": number;
|
||||
@@ -1323,6 +1228,67 @@ export interface components {
|
||||
"preferredAudioTitle": null | string;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
|
||||
};
|
||||
"ScheduleItemResponseModel": {
|
||||
"id": number;
|
||||
"index": number;
|
||||
"startType": components["schemas"]["StartType"];
|
||||
"startTime": null | string;
|
||||
"fixedStartTimeBehavior": null | components["schemas"]["FixedStartTimeBehavior"];
|
||||
"playoutMode": components["schemas"]["PlayoutMode"];
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"rerunCollectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"playlistId": null | number;
|
||||
"searchTitle": null | string;
|
||||
"searchQuery": null | string;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"marathonGroupBy": components["schemas"]["MarathonGroupBy"];
|
||||
"marathonShuffleGroups": boolean;
|
||||
"marathonShuffleItems": boolean;
|
||||
"marathonBatchSize": null | number;
|
||||
"fillWithGroupMode": components["schemas"]["FillWithGroupMode"];
|
||||
"multipleMode": null | components["schemas"]["MultipleMode"];
|
||||
"multipleCount": null | string;
|
||||
"playoutDuration": null | string;
|
||||
"tailMode": null | components["schemas"]["TailMode"];
|
||||
"discardToFillAttempts": null | number;
|
||||
"customTitle": null | string;
|
||||
"guideMode": components["schemas"]["GuideMode"];
|
||||
"preRollFillerId": null | number;
|
||||
"midRollFillerId": null | number;
|
||||
"postRollFillerId": null | number;
|
||||
"tailFillerId": null | number;
|
||||
"fallbackFillerId": null | number;
|
||||
"watermarkIds": Array<number>;
|
||||
"graphicsElementIds": Array<number>;
|
||||
"preferredAudioLanguageCode": null | string;
|
||||
"preferredAudioTitle": null | string;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
|
||||
"collectionName": null | string;
|
||||
"multiCollectionName": null | string;
|
||||
"smartCollectionName": null | string;
|
||||
"rerunCollectionName": null | string;
|
||||
"playlistName": null | string;
|
||||
"playlistGroupId": null | number;
|
||||
"mediaItemName": null | string;
|
||||
"preRollFillerName": null | string;
|
||||
"midRollFillerName": null | string;
|
||||
"postRollFillerName": null | string;
|
||||
"tailFillerName": null | string;
|
||||
"fallbackFillerName": null | string;
|
||||
"watermarks": Array<components["schemas"]["NamedIdResponseModel"]>;
|
||||
"graphicsElements": Array<components["schemas"]["NamedIdResponseModel"]>;
|
||||
"name": null | string;
|
||||
"durationEstimate": null | string;
|
||||
};
|
||||
"ScheduleItemsResponseModel": {
|
||||
"items": Array<components["schemas"]["ScheduleItemResponseModel"]>;
|
||||
"totalDurationEstimate": null | string;
|
||||
};
|
||||
"SchedulingPickerOptionResponseModel": {
|
||||
"id": number;
|
||||
@@ -1713,24 +1679,6 @@ export interface components {
|
||||
"name": null | string;
|
||||
};
|
||||
"WatermarkSize": "Scaled" | "ActualSize";
|
||||
"WatermarkViewModel": {
|
||||
"id": number;
|
||||
"image": components["schemas"]["ArtworkContentTypeModel"];
|
||||
"name": null | string;
|
||||
"mode": components["schemas"]["ChannelWatermarkMode"];
|
||||
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
|
||||
"location": components["schemas"]["WatermarkLocation"];
|
||||
"size": components["schemas"]["WatermarkSize"];
|
||||
"width": number;
|
||||
"horizontalMargin": number;
|
||||
"verticalMargin": number;
|
||||
"frequencyMinutes": number;
|
||||
"durationSeconds": number;
|
||||
"opacity": number;
|
||||
"placeWithinSourceContent": boolean;
|
||||
"opacityExpression": null | string;
|
||||
"zIndex": number;
|
||||
};
|
||||
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
|
||||
"XmltvSettingsResponseModel": {
|
||||
"daysToBuild": number;
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './ffmpegProfiles';
|
||||
export * from './fillerPresets';
|
||||
export * from './guide';
|
||||
export * from './imageFolders';
|
||||
export * from './languages';
|
||||
export * from './libraries';
|
||||
export * from './libraryBrowse';
|
||||
export * from './logs';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLanguages } from './languages';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
describe('getLanguages', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs /api/languages and returns the list', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse([{ code: 'eng', englishName: 'English' }])
|
||||
);
|
||||
|
||||
await expect(getLanguages()).resolves.toEqual([{ code: 'eng', englishName: 'English' }]);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/languages');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({ status: 500, title: 'Internal Server Error' }, 500)
|
||||
);
|
||||
|
||||
await expect(getLanguages()).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type LanguageCode = components['schemas']['LanguageCodeResponseModel'];
|
||||
|
||||
export function getLanguages(): Promise<LanguageCode[]> {
|
||||
return request<LanguageCode[]>('/api/languages');
|
||||
}
|
||||
|
||||
export function messageFromLanguagesError(error: unknown, fallback = 'Unable to load languages'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
+49
-247
@@ -1,277 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getCollections, getSmartCollections, type MediaCollection, type SmartCollection } from './collections';
|
||||
import { getFillerPresets, getWatermarks } from './pickers';
|
||||
import type { FillerPreset, Watermark } from './pickers';
|
||||
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
|
||||
// name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via
|
||||
// api/index.ts) and are no longer defined here — see docs/decisions.md 2026-07-11 (#212).
|
||||
import type { FillerPreset } from './pickers';
|
||||
|
||||
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
|
||||
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
|
||||
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
|
||||
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
|
||||
// once the schema is fixed to describe the concrete subtypes.
|
||||
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
|
||||
count?: null | string;
|
||||
discardToFillAttempts?: null | number;
|
||||
multipleCount?: null | string;
|
||||
multipleMode?: components['schemas']['MultipleMode'];
|
||||
playoutDuration?: null | string;
|
||||
tailMode?: components['schemas']['TailMode'];
|
||||
};
|
||||
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
|
||||
// The schedule item DTO is FLAT (issue #126/#207): every subtype/mutation field is a nullable
|
||||
// top-level member, and its mutation fields are named 1:1 with ScheduleItemRequest so a loaded
|
||||
// item maps back to a PUT/POST body by a straight field copy. See docs/decisions.md 2026-07-10.
|
||||
export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
|
||||
export type ScheduleItemsResponse = components['schemas']['ScheduleItemsResponseModel'];
|
||||
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
|
||||
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
|
||||
export type { FillerPreset, Watermark };
|
||||
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
export type CreateScheduleRequest = components['schemas']['CreateScheduleRequest'];
|
||||
export type UpdateScheduleRequest = components['schemas']['UpdateScheduleRequest'];
|
||||
export type NamedId = components['schemas']['NamedIdResponseModel'];
|
||||
export type FillerKind = components['schemas']['FillerKind'];
|
||||
|
||||
export interface SchedulePickerData {
|
||||
collections: MediaCollection[];
|
||||
fillerPresets: FillerPreset[];
|
||||
smartCollections: SmartCollection[];
|
||||
watermarks: Watermark[];
|
||||
}
|
||||
|
||||
export interface ScheduleScreenData {
|
||||
activeSchedule: ProgramSchedule | null;
|
||||
items: ProgramScheduleItem[];
|
||||
pickers: SchedulePickerData;
|
||||
schedules: ProgramSchedule[];
|
||||
totalDurationEstimate: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleScreenQueryState =
|
||||
| {
|
||||
data: ScheduleScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActiveSchedule: (scheduleId: number) => void;
|
||||
setItems: (items: ProgramScheduleItem[]) => void;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ScheduleScreenState =
|
||||
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
// ---- Schedule CRUD -------------------------------------------------------
|
||||
|
||||
export function getSchedules(): Promise<ProgramSchedule[]> {
|
||||
return request<ProgramSchedule[]>('/api/schedules');
|
||||
}
|
||||
|
||||
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
|
||||
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
|
||||
export function getScheduleById(id: number): Promise<ProgramSchedule> {
|
||||
return request<ProgramSchedule>(`/api/schedules/${id}`);
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
|
||||
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
export function createSchedule(body: CreateScheduleRequest): Promise<ProgramSchedule> {
|
||||
return request<ProgramSchedule>('/api/schedules', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateSchedule(id: number, body: UpdateScheduleRequest): Promise<ProgramSchedule> {
|
||||
return request<ProgramSchedule>(`/api/schedules/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteSchedule(id: number): Promise<void> {
|
||||
return request<void>(`/api/schedules/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ---- Schedule items ------------------------------------------------------
|
||||
|
||||
export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsResponse> {
|
||||
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
|
||||
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
|
||||
}
|
||||
|
||||
// Destructive replace: the server deletes+recreates every item row (new ids) and triggers playout
|
||||
// rebuilds. The editor batches all local draft edits into this single call. See docs/decisions.md.
|
||||
export function replaceScheduleItems(
|
||||
scheduleId: number,
|
||||
body: ReplaceScheduleItemsRequest
|
||||
): Promise<ProgramScheduleItem[]> {
|
||||
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
): Promise<ScheduleItem[]> {
|
||||
return request<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
|
||||
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
|
||||
const schedules = await getSchedules();
|
||||
const activeSchedule = schedules[0] ?? null;
|
||||
// ---- Pickers -------------------------------------------------------------
|
||||
// getLanguages lives in ./languages (imported above); re-exported implicitly via api/index.ts.
|
||||
|
||||
if (!activeSchedule) {
|
||||
return {
|
||||
activeSchedule: null,
|
||||
items: [],
|
||||
pickers: emptyPickerData(),
|
||||
schedules,
|
||||
totalDurationEstimate: null
|
||||
};
|
||||
}
|
||||
|
||||
const [itemsEnvelope, pickers] = await Promise.all([
|
||||
getScheduleItems(activeSchedule.id),
|
||||
getSchedulePickerData()
|
||||
]);
|
||||
|
||||
return {
|
||||
activeSchedule,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
pickers,
|
||||
schedules,
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
};
|
||||
export function getFillerPresetsByKind(fillerKind: FillerKind): Promise<FillerPreset[]> {
|
||||
return request<FillerPreset[]>(`/api/filler-presets?fillerKind=${encodeURIComponent(fillerKind)}`);
|
||||
}
|
||||
|
||||
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
|
||||
const [state, setState] = useState<ScheduleScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const activeScheduleIdRef = useRef<number | null>(null);
|
||||
// The search-backed source pickers (searchTelevisionShows/…/searchMultiCollections) already live in
|
||||
// ./blocks — the inspector imports them from '../api' directly.
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getScheduleScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
|
||||
setState({ data, error: null, itemsLoading: false, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActiveSchedule = useCallback((scheduleId: number) => {
|
||||
activeScheduleIdRef.current = scheduleId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
|
||||
?? current.data.activeSchedule;
|
||||
|
||||
return {
|
||||
data: { ...current.data, activeSchedule: nextActiveSchedule },
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
getScheduleItems(scheduleId)
|
||||
.then((itemsEnvelope) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
data: null,
|
||||
error: messageFromScheduleError(error, 'Unable to load schedule items'),
|
||||
status: 'error'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setItems = useCallback((items: ProgramScheduleItem[]) => {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActiveSchedule,
|
||||
setItems,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
async function getSchedulePickerData(): Promise<SchedulePickerData> {
|
||||
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
|
||||
getCollections(),
|
||||
getSmartCollections(),
|
||||
getFillerPresets(),
|
||||
getWatermarks()
|
||||
]);
|
||||
|
||||
return {
|
||||
collections,
|
||||
fillerPresets,
|
||||
smartCollections,
|
||||
watermarks
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPickerData(): SchedulePickerData {
|
||||
return {
|
||||
collections: [],
|
||||
fillerPresets: [],
|
||||
smartCollections: [],
|
||||
watermarks: []
|
||||
};
|
||||
}
|
||||
|
||||
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
|
||||
export function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canLeaveCurrentScreen, registerNavigationGuard } from './navigationGuard';
|
||||
|
||||
afterEach(() => {
|
||||
// ensure no guard leaks between tests
|
||||
const unregister = registerNavigationGuard(() => true);
|
||||
unregister();
|
||||
});
|
||||
|
||||
describe('navigationGuard', () => {
|
||||
it('allows navigation when no guard is registered', () => {
|
||||
expect(canLeaveCurrentScreen()).toBe(true);
|
||||
});
|
||||
|
||||
it('consults the registered guard', () => {
|
||||
const guard = vi.fn(() => false);
|
||||
const unregister = registerNavigationGuard(guard);
|
||||
expect(canLeaveCurrentScreen()).toBe(false);
|
||||
expect(guard).toHaveBeenCalledTimes(1);
|
||||
unregister();
|
||||
expect(canLeaveCurrentScreen()).toBe(true);
|
||||
});
|
||||
|
||||
it('unregister only clears its own guard', () => {
|
||||
const first = registerNavigationGuard(() => false);
|
||||
const secondGuard = vi.fn(() => false);
|
||||
registerNavigationGuard(secondGuard);
|
||||
first(); // stale unregister must not clear the second guard
|
||||
expect(canLeaveCurrentScreen()).toBe(false);
|
||||
expect(secondGuard).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// A tiny shared guard so a screen with unsaved changes can veto in-app navigation. A screen
|
||||
// registers a predicate (returns true when it is safe to leave, e.g. after a confirm()); App.tsx's
|
||||
// nav handler consults `canLeaveCurrentScreen()` before pushing a new route. Only one guard is
|
||||
// active at a time (the mounted screen) — registering returns an unregister fn for cleanup.
|
||||
//
|
||||
// This does NOT cover full-page unloads (reload / close tab / external link) — a screen additionally
|
||||
// installs its own `beforeunload` listener while dirty for that path.
|
||||
|
||||
type NavigationGuard = () => boolean;
|
||||
|
||||
let activeGuard: NavigationGuard | null = null;
|
||||
|
||||
export function registerNavigationGuard(guard: NavigationGuard): () => void {
|
||||
activeGuard = guard;
|
||||
return () => {
|
||||
if (activeGuard === guard) {
|
||||
activeGuard = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Returns true when navigation may proceed. With no guard registered, always true.
|
||||
export function canLeaveCurrentScreen(): boolean {
|
||||
if (!activeGuard) {
|
||||
return true;
|
||||
}
|
||||
return activeGuard();
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Checkbox, Dialog, Input, Select } from '../components';
|
||||
import {
|
||||
createSchedule,
|
||||
type CreateScheduleRequest,
|
||||
messageFromScheduleError,
|
||||
type ProgramSchedule,
|
||||
updateSchedule
|
||||
} from '../api';
|
||||
|
||||
export interface ScheduleFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
schedule: ProgramSchedule | null;
|
||||
onSaved: (schedule: ProgramSchedule) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Create / edit the six schedule properties. treatCollectionsAsShows is disabled (and the server
|
||||
// coerces it false) unless keepMultiPartEpisodesTogether is on. Unique-name 422 is surfaced inline.
|
||||
export function ScheduleForm({ mode, schedule, onSaved, onClose }: ScheduleFormProps) {
|
||||
const [name, setName] = useState(schedule?.name ?? '');
|
||||
const [keepMultiPart, setKeepMultiPart] = useState(schedule?.keepMultiPartEpisodesTogether ?? false);
|
||||
const [treatAsShows, setTreatAsShows] = useState(schedule?.treatCollectionsAsShows ?? false);
|
||||
const [shuffle, setShuffle] = useState(schedule?.shuffleScheduleItems ?? false);
|
||||
const [randomStart, setRandomStart] = useState(schedule?.randomStartPoint ?? false);
|
||||
const [fixedBehavior, setFixedBehavior] = useState<'Strict' | 'Flexible'>(schedule?.fixedStartTimeBehavior ?? 'Strict');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > 50) {
|
||||
setError('Name must be 50 characters or fewer');
|
||||
return;
|
||||
}
|
||||
|
||||
const body: CreateScheduleRequest = {
|
||||
name: trimmed,
|
||||
keepMultiPartEpisodesTogether: keepMultiPart,
|
||||
treatCollectionsAsShows: keepMultiPart ? treatAsShows : false,
|
||||
shuffleScheduleItems: shuffle,
|
||||
randomStartPoint: randomStart,
|
||||
fixedStartTimeBehavior: fixedBehavior
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const request = mode === 'create' ? createSchedule(body) : updateSchedule(schedule!.id, body);
|
||||
request
|
||||
.then((saved) => {
|
||||
setSaving(false);
|
||||
onSaved(saved);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setSaving(false);
|
||||
setError(messageFromScheduleError(err, 'Unable to save schedule'));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add schedule' : 'Edit schedule'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="ctv-schedule-form-stack">
|
||||
<Input label="Name" value={name} error={error} onChange={(e) => setName(e.target.value)} />
|
||||
<Checkbox
|
||||
label="Keep multi-part episodes together"
|
||||
checked={keepMultiPart}
|
||||
onChange={(v) => {
|
||||
setKeepMultiPart(v);
|
||||
if (!v) {
|
||||
setTreatAsShows(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Treat collections as shows"
|
||||
checked={treatAsShows}
|
||||
disabled={!keepMultiPart}
|
||||
onChange={setTreatAsShows}
|
||||
/>
|
||||
<Checkbox label="Shuffle schedule items" checked={shuffle} onChange={setShuffle} />
|
||||
<p className="ctv-field-help">Shuffling disables fixed start times and flood mode.</p>
|
||||
<Checkbox label="Random start point" checked={randomStart} onChange={setRandomStart} />
|
||||
<Select
|
||||
label="Fixed start time behavior"
|
||||
value={fixedBehavior}
|
||||
onChange={(e) => setFixedBehavior(e.target.value as 'Strict' | 'Flexible')}
|
||||
options={['Strict', 'Flexible']}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Checkbox, Input, Select } from '../components';
|
||||
import {
|
||||
getPlaylists,
|
||||
type FillerPreset,
|
||||
type GraphicsElement,
|
||||
type LanguageCode,
|
||||
type Playlist,
|
||||
type PlaylistGroup,
|
||||
type RerunCollection,
|
||||
type SchedulingPickerOption,
|
||||
searchArtists,
|
||||
searchCollections,
|
||||
searchMultiCollections,
|
||||
searchSmartCollections,
|
||||
searchTelevisionSeasons,
|
||||
searchTelevisionShows,
|
||||
type Watermark
|
||||
} from '../api';
|
||||
import { MultiSelect, SearchPicker } from './pickers';
|
||||
import {
|
||||
applyCollectionTypeChange,
|
||||
applyPlaybackOrderChange,
|
||||
applyPlayoutModeChange,
|
||||
COLLECTION_TYPE_OPTIONS,
|
||||
type DraftItem,
|
||||
durationFieldsEnabled,
|
||||
fillWithGroupModeEligible,
|
||||
FILL_WITH_GROUP_MODE_OPTIONS,
|
||||
fixedBehaviorEnabled,
|
||||
GUIDE_MODE_OPTIONS,
|
||||
humanizeEnum,
|
||||
MARATHON_GROUP_BY_OPTIONS,
|
||||
marathonVisible,
|
||||
multipleCountEnabled,
|
||||
multipleModeEnabled,
|
||||
multipleModeOptions,
|
||||
type PlayoutMode,
|
||||
playbackOrderLabel,
|
||||
playbackOrderOptions,
|
||||
playbackOrderSelectDisabled,
|
||||
playoutModeOptions,
|
||||
shufflePlaylistItemsVisible,
|
||||
startTimeEnabled,
|
||||
startTypeOptions,
|
||||
SUBTITLE_MODE_OPTIONS,
|
||||
TAIL_MODE_OPTIONS
|
||||
} from './itemRules';
|
||||
|
||||
export interface SchedulePickerData {
|
||||
rerunCollections: RerunCollection[];
|
||||
playlistGroups: PlaylistGroup[];
|
||||
watermarks: Watermark[];
|
||||
graphicsElements: GraphicsElement[];
|
||||
languages: LanguageCode[];
|
||||
fillerPreRoll: FillerPreset[];
|
||||
fillerMidRoll: FillerPreset[];
|
||||
fillerPostRoll: FillerPreset[];
|
||||
fillerTail: FillerPreset[];
|
||||
fillerFallback: FillerPreset[];
|
||||
}
|
||||
|
||||
const SEARCH_BY_TYPE: Partial<Record<DraftItem['collectionType'], (q: string) => Promise<SchedulingPickerOption[]>>> = {
|
||||
Collection: searchCollections,
|
||||
MultiCollection: searchMultiCollections,
|
||||
SmartCollection: searchSmartCollections,
|
||||
TelevisionShow: searchTelevisionShows,
|
||||
TelevisionSeason: searchTelevisionSeasons,
|
||||
Artist: searchArtists
|
||||
};
|
||||
|
||||
function enumOptions(values: readonly string[]): Array<{ value: string; label: string }> {
|
||||
return values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||
}
|
||||
|
||||
function fillerOptions(presets: FillerPreset[]): Array<{ value: string; label: string }> {
|
||||
return [{ value: '', label: '(none)' }, ...presets.map((p) => ({ value: `${p.id}`, label: p.name ?? `Filler ${p.id}` }))];
|
||||
}
|
||||
|
||||
// Hours+minutes -> "hh:mm:00", clamped to [1 minute, 24 hours].
|
||||
function toDurationString(hours: number, minutes: number): string {
|
||||
const total = Math.min(Math.max(hours * 60 + minutes, 1), 24 * 60);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function parseDuration(value: null | string): { hours: number; minutes: number } {
|
||||
if (!value) {
|
||||
return { hours: 0, minutes: 0 };
|
||||
}
|
||||
const [h, m] = value.split(':');
|
||||
return { hours: Number(h) || 0, minutes: Number(m) || 0 };
|
||||
}
|
||||
|
||||
export interface ScheduleItemInspectorProps {
|
||||
item: DraftItem;
|
||||
shuffleScheduleItems: boolean;
|
||||
pickers: SchedulePickerData;
|
||||
// When true, the whole inspector is non-interactive (an items load is in flight/failed, or a Save
|
||||
// is in flight) — interaction is suppressed so no edit is dropped by the Save .then (F4). The
|
||||
// parent's mutate() also no-ops in this state; this is the matching visual/pointer block.
|
||||
disabled?: boolean;
|
||||
onChange: (next: DraftItem) => void;
|
||||
}
|
||||
|
||||
export function ScheduleItemInspector({ item, shuffleScheduleItems, pickers, disabled, onChange }: ScheduleItemInspectorProps) {
|
||||
const patch = (changes: Partial<DraftItem>) => onChange({ ...item, ...changes });
|
||||
const duration = parseDuration(item.playoutDuration);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ctv-schedule-inspector-body"
|
||||
aria-disabled={disabled || undefined}
|
||||
style={disabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
|
||||
>
|
||||
<SourceSection item={item} onChange={onChange} pickers={pickers} />
|
||||
|
||||
<fieldset className="ctv-schedule-fieldset">
|
||||
<legend>Start</legend>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Start type"
|
||||
value={item.startType}
|
||||
onChange={(e) => patch({ startType: e.target.value as DraftItem['startType'] })}
|
||||
options={enumOptions(startTypeOptions(shuffleScheduleItems))}
|
||||
/>
|
||||
<Input
|
||||
label="Start time"
|
||||
type="time"
|
||||
value={item.startTime ? item.startTime.slice(0, 5) : ''}
|
||||
disabled={!startTimeEnabled(item)}
|
||||
onChange={(e) => patch({ startTime: e.target.value ? `${e.target.value}:00` : null })}
|
||||
/>
|
||||
<Select
|
||||
label="Fixed start behavior"
|
||||
value={item.fixedStartTimeBehavior ?? ''}
|
||||
disabled={!fixedBehaviorEnabled(item)}
|
||||
onChange={(e) =>
|
||||
patch({ fixedStartTimeBehavior: e.target.value ? (e.target.value as DraftItem['fixedStartTimeBehavior']) : null })
|
||||
}
|
||||
options={[{ value: '', label: 'Inherit' }, ...enumOptions(['Strict', 'Flexible'])]}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="ctv-schedule-fieldset">
|
||||
<legend>Playback</legend>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Playback order"
|
||||
value={item.playbackOrder}
|
||||
disabled={playbackOrderSelectDisabled(item.collectionType)}
|
||||
onChange={(e) => onChange(applyPlaybackOrderChange(item, e.target.value as DraftItem['playbackOrder']))}
|
||||
options={playbackOrderOptions(item.collectionType).map((order) => ({
|
||||
value: order,
|
||||
label: playbackOrderLabel(order, item.collectionType)
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Playout mode"
|
||||
value={item.playoutMode}
|
||||
onChange={(e) => onChange(applyPlayoutModeChange(item, e.target.value as PlayoutMode))}
|
||||
options={enumOptions(playoutModeOptions(shuffleScheduleItems))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{marathonVisible(item) && (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Marathon group by"
|
||||
value={item.marathonGroupBy}
|
||||
onChange={(e) => patch({ marathonGroupBy: e.target.value as DraftItem['marathonGroupBy'] })}
|
||||
options={enumOptions(MARATHON_GROUP_BY_OPTIONS)}
|
||||
/>
|
||||
<Input
|
||||
label="Batch size (empty plays all)"
|
||||
type="number"
|
||||
value={item.marathonBatchSize == null ? '' : `${item.marathonBatchSize}`}
|
||||
onChange={(e) => patch({ marathonBatchSize: e.target.value ? Number(e.target.value) : null })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Shuffle groups"
|
||||
checked={item.marathonShuffleGroups}
|
||||
onChange={(v) => patch({ marathonShuffleGroups: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Shuffle items"
|
||||
checked={item.marathonShuffleItems}
|
||||
onChange={(v) => patch({ marathonShuffleItems: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shufflePlaylistItemsVisible(item) && (
|
||||
<Checkbox
|
||||
label="Shuffle Playlist Items"
|
||||
checked={item.marathonShuffleGroups}
|
||||
onChange={(v) => patch({ marathonShuffleGroups: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Multiple mode"
|
||||
value={item.multipleMode}
|
||||
disabled={!multipleModeEnabled(item)}
|
||||
onChange={(e) => patch({ multipleMode: e.target.value as DraftItem['multipleMode'] })}
|
||||
options={enumOptions(multipleModeOptions(item.collectionType, item.playbackOrder))}
|
||||
/>
|
||||
<Input
|
||||
label="Multiple count"
|
||||
value={item.multipleCount ?? ''}
|
||||
disabled={!multipleCountEnabled(item)}
|
||||
placeholder="expression may use 'count' and 'random'"
|
||||
onChange={(e) => patch({ multipleCount: e.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{durationFieldsEnabled(item) && (
|
||||
<>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Input
|
||||
label="Duration (hours)"
|
||||
type="number"
|
||||
value={`${duration.hours}`}
|
||||
onChange={(e) => patch({ playoutDuration: toDurationString(Number(e.target.value) || 0, duration.minutes) })}
|
||||
/>
|
||||
<Input
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={`${duration.minutes}`}
|
||||
onChange={(e) => patch({ playoutDuration: toDurationString(duration.hours, Number(e.target.value) || 0) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Tail mode"
|
||||
value={item.tailMode}
|
||||
onChange={(e) => patch({ tailMode: e.target.value as DraftItem['tailMode'] })}
|
||||
options={enumOptions(TAIL_MODE_OPTIONS)}
|
||||
/>
|
||||
<Input
|
||||
label="Discard to fill attempts"
|
||||
type="number"
|
||||
value={item.discardToFillAttempts == null ? '' : `${item.discardToFillAttempts}`}
|
||||
onChange={(e) => patch({ discardToFillAttempts: e.target.value ? Number(e.target.value) : 0 })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Fill with group mode"
|
||||
value={item.fillWithGroupMode}
|
||||
disabled={!fillWithGroupModeEligible(item)}
|
||||
onChange={(e) => patch({ fillWithGroupMode: e.target.value as DraftItem['fillWithGroupMode'] })}
|
||||
options={enumOptions(FILL_WITH_GROUP_MODE_OPTIONS)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="ctv-schedule-fieldset">
|
||||
<legend>Filler</legend>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select label="Pre-roll" value={`${item.preRollFillerId ?? ''}`} onChange={(e) => patch({ preRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerPreRoll)} />
|
||||
<Select label="Mid-roll" value={`${item.midRollFillerId ?? ''}`} onChange={(e) => patch({ midRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerMidRoll)} />
|
||||
<Select label="Post-roll" value={`${item.postRollFillerId ?? ''}`} onChange={(e) => patch({ postRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerPostRoll)} />
|
||||
<Select label="Tail" value={`${item.tailFillerId ?? ''}`} onChange={(e) => patch({ tailFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerTail)} />
|
||||
<Select label="Fallback" value={`${item.fallbackFillerId ?? ''}`} onChange={(e) => patch({ fallbackFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerFallback)} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="ctv-schedule-fieldset">
|
||||
<legend>Overrides</legend>
|
||||
<Input label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" onChange={(e) => patch({ customTitle: e.target.value || null })} />
|
||||
<Select
|
||||
label="Guide mode"
|
||||
value={item.guideMode}
|
||||
onChange={(e) => patch({ guideMode: e.target.value as DraftItem['guideMode'] })}
|
||||
options={enumOptions(GUIDE_MODE_OPTIONS)}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Watermarks"
|
||||
options={pickers.watermarks.map((w) => ({ id: w.id, name: w.name ?? `Watermark ${w.id}` }))}
|
||||
selectedIds={item.watermarkIds ?? []}
|
||||
onChange={(ids) => patch({ watermarkIds: ids })}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Graphics elements"
|
||||
options={pickers.graphicsElements.map((g) => ({ id: g.id, name: g.name ?? `Graphics ${g.id}` }))}
|
||||
selectedIds={item.graphicsElementIds ?? []}
|
||||
onChange={(ids) => patch({ graphicsElementIds: ids })}
|
||||
/>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Preferred audio language"
|
||||
value={item.preferredAudioLanguageCode ?? ''}
|
||||
onChange={(e) => patch({ preferredAudioLanguageCode: e.target.value || null })}
|
||||
options={[{ value: '', label: '(none)' }, ...pickers.languages.map((l) => ({ value: l.code ?? '', label: l.englishName ?? l.code ?? '' }))]}
|
||||
/>
|
||||
<Input label="Preferred audio title" value={item.preferredAudioTitle ?? ''} onChange={(e) => patch({ preferredAudioTitle: e.target.value || null })} />
|
||||
</div>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Preferred subtitle language"
|
||||
value={item.preferredSubtitleLanguageCode ?? ''}
|
||||
onChange={(e) => patch({ preferredSubtitleLanguageCode: e.target.value || null })}
|
||||
options={[{ value: '', label: '(none)' }, ...pickers.languages.map((l) => ({ value: l.code ?? '', label: l.englishName ?? l.code ?? '' }))]}
|
||||
/>
|
||||
<Select
|
||||
label="Subtitle mode"
|
||||
value={item.subtitleMode ?? ''}
|
||||
onChange={(e) => patch({ subtitleMode: e.target.value ? (e.target.value as DraftItem['subtitleMode']) : null })}
|
||||
options={SUBTITLE_MODE_OPTIONS.map((mode) => ({ value: mode ?? '', label: mode == null ? 'Inherit' : mode }))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collection type + the source picker that varies by type.
|
||||
function SourceSection({
|
||||
item,
|
||||
onChange,
|
||||
pickers
|
||||
}: {
|
||||
item: DraftItem;
|
||||
onChange: (next: DraftItem) => void;
|
||||
pickers: SchedulePickerData;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="ctv-schedule-fieldset">
|
||||
<legend>Source</legend>
|
||||
<Select
|
||||
label="Collection type"
|
||||
value={item.collectionType}
|
||||
onChange={(e) => onChange(applyCollectionTypeChange(item, e.target.value as DraftItem['collectionType']))}
|
||||
options={enumOptions(COLLECTION_TYPE_OPTIONS)}
|
||||
/>
|
||||
<SourcePicker item={item} onChange={onChange} pickers={pickers} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePicker({
|
||||
item,
|
||||
onChange,
|
||||
pickers
|
||||
}: {
|
||||
item: DraftItem;
|
||||
onChange: (next: DraftItem) => void;
|
||||
pickers: SchedulePickerData;
|
||||
}) {
|
||||
const type = item.collectionType;
|
||||
|
||||
if (type === 'SearchQuery') {
|
||||
return (
|
||||
<>
|
||||
<Input label="Search title" value={item.searchTitle ?? ''} onChange={(e) => onChange({ ...item, searchTitle: e.target.value || null })} />
|
||||
<Input label="Search query" value={item.searchQuery ?? ''} onChange={(e) => onChange({ ...item, searchQuery: e.target.value || null })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'Playlist') {
|
||||
return <PlaylistPicker item={item} onChange={onChange} groups={pickers.playlistGroups} />;
|
||||
}
|
||||
|
||||
if (type === 'RerunFirstRun' || type === 'RerunRerun') {
|
||||
return (
|
||||
<Select
|
||||
label="Rerun collection"
|
||||
value={`${item.rerunCollectionId ?? ''}`}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? Number(e.target.value) : null;
|
||||
const name = pickers.rerunCollections.find((c) => c.id === id)?.name ?? null;
|
||||
onChange({ ...item, rerunCollectionId: id, rerunCollectionName: name });
|
||||
}}
|
||||
options={[{ value: '', label: '(none)' }, ...pickers.rerunCollections.map((c) => ({ value: `${c.id}`, label: c.name ?? `Rerun ${c.id}` }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const search = SEARCH_BY_TYPE[type];
|
||||
if (!search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Media-item-backed types store mediaItemId; collection-backed store the matching id field.
|
||||
const isMediaItem = type === 'TelevisionShow' || type === 'TelevisionSeason' || type === 'Artist';
|
||||
const selectedId = isMediaItem
|
||||
? item.mediaItemId
|
||||
: type === 'Collection'
|
||||
? item.collectionId
|
||||
: type === 'MultiCollection'
|
||||
? item.multiCollectionId
|
||||
: item.smartCollectionId;
|
||||
const selectedName = isMediaItem
|
||||
? item.mediaItemName
|
||||
: type === 'Collection'
|
||||
? item.collectionName
|
||||
: type === 'MultiCollection'
|
||||
? item.multiCollectionName
|
||||
: item.smartCollectionName;
|
||||
|
||||
const setSelection = (id: null | number, name: null | string) => {
|
||||
if (isMediaItem) {
|
||||
onChange({ ...item, mediaItemId: id, mediaItemName: name });
|
||||
} else if (type === 'Collection') {
|
||||
onChange({ ...item, collectionId: id, collectionName: name });
|
||||
} else if (type === 'MultiCollection') {
|
||||
onChange({ ...item, multiCollectionId: id, multiCollectionName: name });
|
||||
} else {
|
||||
onChange({ ...item, smartCollectionId: id, smartCollectionName: name });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchPicker
|
||||
label={humanizeEnum(type)}
|
||||
selectedId={selectedId ?? null}
|
||||
selectedName={selectedName ?? null}
|
||||
search={search}
|
||||
onSelect={(id, name) => setSelection(id, name)}
|
||||
onClear={() => setSelection(null, null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistPicker({
|
||||
item,
|
||||
onChange,
|
||||
groups
|
||||
}: {
|
||||
item: DraftItem;
|
||||
onChange: (next: DraftItem) => void;
|
||||
groups: PlaylistGroup[];
|
||||
}) {
|
||||
const [groupId, setGroupId] = useState<null | number>(item.playlistGroupId ?? null);
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const seqRef = useRef(0);
|
||||
|
||||
// Fetch playlists for the selected group. When no group is selected the Select is disabled, so a
|
||||
// stale list is harmless and we avoid a synchronous setState in the effect body (spa-conventions §3).
|
||||
useEffect(() => {
|
||||
if (groupId == null) {
|
||||
return;
|
||||
}
|
||||
const seq = ++seqRef.current;
|
||||
getPlaylists(groupId)
|
||||
.then((items) => {
|
||||
if (seqRef.current === seq) {
|
||||
setPlaylists(items);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (seqRef.current === seq) {
|
||||
setPlaylists([]);
|
||||
}
|
||||
});
|
||||
}, [groupId]);
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select
|
||||
label="Playlist group"
|
||||
value={`${groupId ?? ''}`}
|
||||
onChange={(e) => {
|
||||
const nextGroup = e.target.value ? Number(e.target.value) : null;
|
||||
setGroupId(nextGroup);
|
||||
onChange({ ...item, playlistGroupId: nextGroup, playlistId: null, playlistName: null });
|
||||
}}
|
||||
options={[{ value: '', label: '(select group)' }, ...groups.map((g) => ({ value: `${g.id}`, label: g.name ?? `Group ${g.id}` }))]}
|
||||
/>
|
||||
<Select
|
||||
label="Playlist"
|
||||
value={`${item.playlistId ?? ''}`}
|
||||
disabled={groupId == null}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? Number(e.target.value) : null;
|
||||
const name = playlists.find((p) => p.id === id)?.name ?? null;
|
||||
onChange({ ...item, playlistId: id, playlistName: name });
|
||||
}}
|
||||
options={[{ value: '', label: '(none)' }, ...playlists.map((p) => ({ value: `${p.id}`, label: p.name ?? `Playlist ${p.id}` }))]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyCollectionTypeChange,
|
||||
applyPlaybackOrderChange,
|
||||
applyPlayoutModeChange,
|
||||
copyDraftItem,
|
||||
type DraftItem,
|
||||
durationFieldsEnabled,
|
||||
fillWithGroupModeEligible,
|
||||
fixedBehaviorEnabled,
|
||||
fromResponse,
|
||||
multipleCountEnabled,
|
||||
multipleModeEnabled,
|
||||
multipleModeOptions,
|
||||
newDraftItem,
|
||||
normalizeForSave,
|
||||
playbackOrderLabel,
|
||||
playbackOrderOptions,
|
||||
playbackOrderSelectDisabled,
|
||||
playoutModeOptions,
|
||||
type ScheduleItem,
|
||||
shufflePlaylistItemsVisible,
|
||||
startTimeEnabled,
|
||||
startTypeOptions,
|
||||
validateDraft,
|
||||
validateItem
|
||||
} from './itemRules';
|
||||
|
||||
function draft(overrides: Partial<DraftItem> = {}): DraftItem {
|
||||
return { ...newDraftItem(), ...overrides };
|
||||
}
|
||||
|
||||
describe('playbackOrderOptions', () => {
|
||||
it('MultiCollection offers Shuffle + ShuffleInOrder only', () => {
|
||||
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder']);
|
||||
});
|
||||
|
||||
it('Collection / SmartCollection / SearchQuery offer the collection-like set incl. Marathon', () => {
|
||||
const expected = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
|
||||
expect(playbackOrderOptions('Collection')).toEqual(expected);
|
||||
expect(playbackOrderOptions('SmartCollection')).toEqual(expected);
|
||||
expect(playbackOrderOptions('SearchQuery')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('TelevisionShow offers Chronological/SeasonEpisode/Random/Shuffle/MultiEpisodeShuffle', () => {
|
||||
expect(playbackOrderOptions('TelevisionShow')).toEqual([
|
||||
'Chronological',
|
||||
'SeasonEpisode',
|
||||
'Random',
|
||||
'Shuffle',
|
||||
'MultiEpisodeShuffle'
|
||||
]);
|
||||
});
|
||||
|
||||
it('TelevisionSeason / Artist offer Chronological/Random/Shuffle', () => {
|
||||
expect(playbackOrderOptions('TelevisionSeason')).toEqual(['Chronological', 'Random', 'Shuffle']);
|
||||
expect(playbackOrderOptions('Artist')).toEqual(['Chronological', 'Random', 'Shuffle']);
|
||||
});
|
||||
|
||||
it('Playlist / Rerun types offer only None', () => {
|
||||
expect(playbackOrderOptions('Playlist')).toEqual(['None']);
|
||||
expect(playbackOrderOptions('RerunFirstRun')).toEqual(['None']);
|
||||
expect(playbackOrderOptions('RerunRerun')).toEqual(['None']);
|
||||
});
|
||||
|
||||
it('never offers RandomRotation', () => {
|
||||
const allTypes: DraftItem['collectionType'][] = [
|
||||
'Collection',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist',
|
||||
'MultiCollection',
|
||||
'SmartCollection',
|
||||
'Playlist',
|
||||
'RerunFirstRun',
|
||||
'RerunRerun',
|
||||
'SearchQuery'
|
||||
];
|
||||
for (const t of allTypes) {
|
||||
expect(playbackOrderOptions(t)).not.toContain('RandomRotation');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackOrder selector disable + label', () => {
|
||||
it('disabled for Playlist and Rerun types only', () => {
|
||||
expect(playbackOrderSelectDisabled('Playlist')).toBe(true);
|
||||
expect(playbackOrderSelectDisabled('RerunFirstRun')).toBe(true);
|
||||
expect(playbackOrderSelectDisabled('RerunRerun')).toBe(true);
|
||||
expect(playbackOrderSelectDisabled('Collection')).toBe(false);
|
||||
expect(playbackOrderSelectDisabled('MultiCollection')).toBe(false);
|
||||
});
|
||||
|
||||
it('labels None as "Playlist" for a Playlist type, plain otherwise', () => {
|
||||
expect(playbackOrderLabel('None', 'Playlist')).toBe('Playlist');
|
||||
expect(playbackOrderLabel('None', 'RerunFirstRun')).toBe('None');
|
||||
expect(playbackOrderLabel('MultiEpisodeShuffle', 'TelevisionShow')).toBe('Multi Episode Shuffle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start + playout mode option lists gate on shuffleScheduleItems', () => {
|
||||
it('hides Fixed start type when the schedule shuffles items', () => {
|
||||
expect(startTypeOptions(false)).toEqual(['Dynamic', 'Fixed']);
|
||||
expect(startTypeOptions(true)).toEqual(['Dynamic']);
|
||||
});
|
||||
|
||||
it('hides Flood playout mode when the schedule shuffles items', () => {
|
||||
expect(playoutModeOptions(false)).toEqual(['Flood', 'One', 'Multiple', 'Duration']);
|
||||
expect(playoutModeOptions(true)).toEqual(['One', 'Multiple', 'Duration']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipleModeOptions', () => {
|
||||
it('always offers Count; CollectionSize for non-Playlist, PlaylistItemSize for Playlist', () => {
|
||||
expect(multipleModeOptions('Collection', 'Shuffle')).toEqual(['Count', 'CollectionSize']);
|
||||
expect(multipleModeOptions('Playlist', 'None')).toEqual(['Count', 'PlaylistItemSize']);
|
||||
});
|
||||
|
||||
it('adds MultiEpisodeGroupSize only for Chronological order', () => {
|
||||
expect(multipleModeOptions('Collection', 'Chronological')).toEqual([
|
||||
'Count',
|
||||
'CollectionSize',
|
||||
'MultiEpisodeGroupSize'
|
||||
]);
|
||||
expect(multipleModeOptions('Collection', 'Random')).not.toContain('MultiEpisodeGroupSize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable gates', () => {
|
||||
it('start time + fixed behavior enabled only for Fixed', () => {
|
||||
expect(startTimeEnabled(draft({ startType: 'Fixed' }))).toBe(true);
|
||||
expect(startTimeEnabled(draft({ startType: 'Dynamic' }))).toBe(false);
|
||||
expect(fixedBehaviorEnabled(draft({ startType: 'Fixed' }))).toBe(true);
|
||||
expect(fixedBehaviorEnabled(draft({ startType: 'Dynamic' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('multipleMode enabled only for Multiple; multipleCount only for Multiple+Count', () => {
|
||||
expect(multipleModeEnabled(draft({ playoutMode: 'Multiple' }))).toBe(true);
|
||||
expect(multipleModeEnabled(draft({ playoutMode: 'One' }))).toBe(false);
|
||||
expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'Count' }))).toBe(true);
|
||||
expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize' }))).toBe(false);
|
||||
expect(multipleCountEnabled(draft({ playoutMode: 'Duration', multipleMode: 'Count' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('duration fields enabled only for Duration', () => {
|
||||
expect(durationFieldsEnabled(draft({ playoutMode: 'Duration' }))).toBe(true);
|
||||
expect(durationFieldsEnabled(draft({ playoutMode: 'Flood' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('shuffle-playlist-items checkbox visible only for Playlist type', () => {
|
||||
expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Playlist' }))).toBe(true);
|
||||
expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Collection' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillWithGroupModeEligible', () => {
|
||||
it('eligible for Multiple/Duration + non-ShuffleInOrder + Collection/Multi/Smart', () => {
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection' }))).toBe(true);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'MultiCollection' }))).toBe(true);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'SmartCollection' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ineligible for One/Flood, ShuffleInOrder, or non-collection types', () => {
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'One', collectionType: 'Collection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Flood', collectionType: 'Collection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'ShuffleInOrder', collectionType: 'Collection' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'TelevisionShow' }))).toBe(false);
|
||||
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'None', collectionType: 'Playlist' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCollectionTypeChange', () => {
|
||||
it('nulls all source refs + names', () => {
|
||||
const before = draft({
|
||||
collectionType: 'Collection',
|
||||
collectionId: 5,
|
||||
collectionName: 'A',
|
||||
multiCollectionId: 6,
|
||||
multiCollectionName: 'B',
|
||||
smartCollectionId: 7,
|
||||
smartCollectionName: 'C',
|
||||
rerunCollectionId: 8,
|
||||
rerunCollectionName: 'D',
|
||||
mediaItemId: 9,
|
||||
mediaItemName: 'E',
|
||||
playlistId: 10,
|
||||
playlistName: 'F',
|
||||
playlistGroupId: 2,
|
||||
searchTitle: 'T',
|
||||
searchQuery: 'Q'
|
||||
});
|
||||
const after = applyCollectionTypeChange(before, 'TelevisionShow');
|
||||
expect(after.collectionType).toBe('TelevisionShow');
|
||||
for (const key of [
|
||||
'collectionId',
|
||||
'multiCollectionId',
|
||||
'smartCollectionId',
|
||||
'rerunCollectionId',
|
||||
'mediaItemId',
|
||||
'playlistId',
|
||||
'playlistGroupId',
|
||||
'searchTitle',
|
||||
'searchQuery',
|
||||
'collectionName',
|
||||
'multiCollectionName',
|
||||
'smartCollectionName',
|
||||
'rerunCollectionName',
|
||||
'playlistName',
|
||||
'mediaItemName'
|
||||
] as const) {
|
||||
expect(after[key]).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets PlaylistItemSize -> Count when leaving Playlist', () => {
|
||||
const before = draft({ collectionType: 'Playlist', playbackOrder: 'None', multipleMode: 'PlaylistItemSize' });
|
||||
expect(applyCollectionTypeChange(before, 'Collection').multipleMode).toBe('Count');
|
||||
});
|
||||
|
||||
it('resets CollectionSize -> Count when moving to Playlist (invalid there)', () => {
|
||||
// CollectionSize is not offered for a Playlist type (which offers PlaylistItemSize), so it must
|
||||
// snap to the first valid mode — otherwise the native <select> shows "Count" while state kept
|
||||
// "CollectionSize" (F2 repro).
|
||||
const before = draft({ collectionType: 'Collection', multipleMode: 'CollectionSize' });
|
||||
expect(applyCollectionTypeChange(before, 'Playlist').multipleMode).toBe('Count');
|
||||
});
|
||||
|
||||
it('keeps PlaylistItemSize when moving to Playlist (valid there)', () => {
|
||||
const before = draft({ collectionType: 'Collection', multipleMode: 'PlaylistItemSize' });
|
||||
expect(applyCollectionTypeChange(before, 'Playlist').multipleMode).toBe('PlaylistItemSize');
|
||||
});
|
||||
|
||||
it('F2: Collection+Marathon -> TelevisionShow drops Marathon (order + fields reconciled)', () => {
|
||||
// Repro: TelevisionShow does not offer Marathon; a stale playbackOrder:"Marathon" left the
|
||||
// select displaying "Chronological" while Marathon fields stayed visible.
|
||||
const before = draft({
|
||||
collectionType: 'Collection',
|
||||
playbackOrder: 'Marathon',
|
||||
marathonGroupBy: 'Season',
|
||||
marathonShuffleItems: true,
|
||||
marathonBatchSize: 4
|
||||
});
|
||||
const after = applyCollectionTypeChange(before, 'TelevisionShow');
|
||||
expect(playbackOrderOptions('TelevisionShow')).not.toContain('Marathon');
|
||||
expect(after.playbackOrder).toBe('Chronological'); // first TelevisionShow order
|
||||
expect(after.marathonGroupBy).toBe('None');
|
||||
expect(after.marathonShuffleItems).toBe(false);
|
||||
expect(after.marathonBatchSize).toBeNull();
|
||||
});
|
||||
|
||||
it('F2 invariant: for every from/to type pair, playbackOrder and multipleMode land in the valid sets', () => {
|
||||
const allTypes: DraftItem['collectionType'][] = [
|
||||
'Collection',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist',
|
||||
'MultiCollection',
|
||||
'SmartCollection',
|
||||
'Playlist',
|
||||
'RerunFirstRun',
|
||||
'RerunRerun',
|
||||
'SearchQuery'
|
||||
];
|
||||
const seedModes: DraftItem['multipleMode'][] = ['Count', 'CollectionSize', 'PlaylistItemSize', 'MultiEpisodeGroupSize'];
|
||||
for (const from of allTypes) {
|
||||
for (const order of playbackOrderOptions(from)) {
|
||||
for (const mode of seedModes) {
|
||||
const before = draft({ collectionType: from, playbackOrder: order, multipleMode: mode });
|
||||
for (const to of allTypes) {
|
||||
const after = applyCollectionTypeChange(before, to);
|
||||
expect(playbackOrderOptions(to)).toContain(after.playbackOrder);
|
||||
expect(multipleModeOptions(to, after.playbackOrder)).toContain(after.multipleMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('forces playbackOrder None for Playlist / Rerun types', () => {
|
||||
expect(applyCollectionTypeChange(draft(), 'Playlist').playbackOrder).toBe('None');
|
||||
expect(applyCollectionTypeChange(draft(), 'RerunFirstRun').playbackOrder).toBe('None');
|
||||
expect(applyCollectionTypeChange(draft(), 'RerunRerun').playbackOrder).toBe('None');
|
||||
});
|
||||
|
||||
it('forces playbackOrder Shuffle for MultiCollection', () => {
|
||||
expect(applyCollectionTypeChange(draft({ playbackOrder: 'Chronological' }), 'MultiCollection').playbackOrder).toBe('Shuffle');
|
||||
});
|
||||
|
||||
it('resets fillWithGroupMode when the new type makes it ineligible', () => {
|
||||
const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' });
|
||||
expect(before.fillWithGroupMode).toBe('FillWithOrderedGroups');
|
||||
const after = applyCollectionTypeChange(before, 'TelevisionShow');
|
||||
expect(after.fillWithGroupMode).toBe('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPlaybackOrderChange', () => {
|
||||
it('resets MultiEpisodeGroupSize -> Count when not Chronological', () => {
|
||||
const before = draft({ playbackOrder: 'Chronological', multipleMode: 'MultiEpisodeGroupSize' });
|
||||
expect(applyPlaybackOrderChange(before, 'Random').multipleMode).toBe('Count');
|
||||
expect(applyPlaybackOrderChange(before, 'Chronological').multipleMode).toBe('MultiEpisodeGroupSize');
|
||||
});
|
||||
|
||||
it('resets marathon fields (except shuffleGroups) when not Marathon', () => {
|
||||
const before = draft({
|
||||
playbackOrder: 'Marathon',
|
||||
marathonGroupBy: 'Season',
|
||||
marathonShuffleItems: true,
|
||||
marathonShuffleGroups: true,
|
||||
marathonBatchSize: 4
|
||||
});
|
||||
const after = applyPlaybackOrderChange(before, 'Shuffle');
|
||||
expect(after.marathonGroupBy).toBe('None');
|
||||
expect(after.marathonShuffleItems).toBe(false);
|
||||
expect(after.marathonBatchSize).toBeNull();
|
||||
expect(after.marathonShuffleGroups).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves marathon fields when staying Marathon', () => {
|
||||
const before = draft({ playbackOrder: 'Marathon', marathonGroupBy: 'Season', marathonBatchSize: 4 });
|
||||
const after = applyPlaybackOrderChange(before, 'Marathon');
|
||||
expect(after.marathonGroupBy).toBe('Season');
|
||||
expect(after.marathonBatchSize).toBe(4);
|
||||
});
|
||||
|
||||
it('resets fillWithGroupMode to None for ShuffleInOrder', () => {
|
||||
const before = draft({ playoutMode: 'Multiple', collectionType: 'Collection', fillWithGroupMode: 'FillWithShuffledGroups' });
|
||||
expect(applyPlaybackOrderChange(before, 'ShuffleInOrder').fillWithGroupMode).toBe('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPlayoutModeChange', () => {
|
||||
it('resets fillWithGroupMode when moving to One/Flood', () => {
|
||||
const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' });
|
||||
expect(applyPlayoutModeChange(before, 'One').fillWithGroupMode).toBe('None');
|
||||
expect(applyPlayoutModeChange(before, 'Duration').fillWithGroupMode).toBe('FillWithOrderedGroups');
|
||||
});
|
||||
});
|
||||
|
||||
describe('factories', () => {
|
||||
it('newDraftItem uses the documented defaults', () => {
|
||||
const item = newDraftItem();
|
||||
expect(item).toMatchObject({
|
||||
startType: 'Dynamic',
|
||||
playoutMode: 'One',
|
||||
playbackOrder: 'Shuffle',
|
||||
collectionType: 'Collection',
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: []
|
||||
});
|
||||
expect(item._key).toMatch(/^draft-/);
|
||||
});
|
||||
|
||||
it('copyDraftItem deep-copies multi/smart/rerun refs and gives a new key + array identity', () => {
|
||||
const original = draft({
|
||||
multiCollectionId: 3,
|
||||
multiCollectionName: 'M',
|
||||
smartCollectionId: 4,
|
||||
smartCollectionName: 'S',
|
||||
rerunCollectionId: 5,
|
||||
rerunCollectionName: 'R',
|
||||
watermarkIds: [1, 2],
|
||||
graphicsElementIds: [9]
|
||||
});
|
||||
const copy = copyDraftItem(original);
|
||||
expect(copy.multiCollectionId).toBe(3);
|
||||
expect(copy.smartCollectionId).toBe(4);
|
||||
expect(copy.rerunCollectionId).toBe(5);
|
||||
expect(copy.multiCollectionName).toBe('M');
|
||||
expect(copy.rerunCollectionName).toBe('R');
|
||||
expect(copy._key).not.toBe(original._key);
|
||||
expect(copy.watermarkIds).toEqual([1, 2]);
|
||||
expect(copy.watermarkIds).not.toBe(original.watermarkIds);
|
||||
expect(copy.graphicsElementIds).not.toBe(original.graphicsElementIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromResponse', () => {
|
||||
it('maps watermarks/graphics named embeds to id arrays and copies mutation fields', () => {
|
||||
const model: ScheduleItem = {
|
||||
id: 42,
|
||||
index: 0,
|
||||
startType: 'Fixed',
|
||||
startTime: '06:00:00',
|
||||
fixedStartTimeBehavior: 'Strict',
|
||||
playoutMode: 'Duration',
|
||||
collectionType: 'SmartCollection',
|
||||
collectionId: null,
|
||||
multiCollectionId: null,
|
||||
smartCollectionId: 7,
|
||||
rerunCollectionId: null,
|
||||
mediaItemId: null,
|
||||
playlistId: null,
|
||||
searchTitle: null,
|
||||
searchQuery: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
marathonBatchSize: null,
|
||||
fillWithGroupMode: 'FillWithOrderedGroups',
|
||||
multipleMode: null,
|
||||
multipleCount: null,
|
||||
playoutDuration: '01:00:00',
|
||||
tailMode: 'Offline',
|
||||
discardToFillAttempts: 2,
|
||||
customTitle: 'Nightly',
|
||||
guideMode: 'Normal',
|
||||
preRollFillerId: null,
|
||||
midRollFillerId: null,
|
||||
postRollFillerId: null,
|
||||
tailFillerId: null,
|
||||
fallbackFillerId: null,
|
||||
watermarkIds: [1, 2],
|
||||
graphicsElementIds: [3],
|
||||
preferredAudioLanguageCode: 'eng',
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: null,
|
||||
collectionName: null,
|
||||
multiCollectionName: null,
|
||||
smartCollectionName: 'Smarts',
|
||||
rerunCollectionName: null,
|
||||
playlistName: null,
|
||||
playlistGroupId: null,
|
||||
mediaItemName: null,
|
||||
preRollFillerName: null,
|
||||
midRollFillerName: null,
|
||||
postRollFillerName: null,
|
||||
tailFillerName: null,
|
||||
fallbackFillerName: null,
|
||||
watermarks: [
|
||||
{ id: 1, name: 'W1' },
|
||||
{ id: 2, name: 'W2' }
|
||||
],
|
||||
graphicsElements: [{ id: 3, name: 'G3' }],
|
||||
name: 'Smarts',
|
||||
durationEstimate: '01:00:00'
|
||||
};
|
||||
const item = fromResponse(model);
|
||||
expect(item.smartCollectionId).toBe(7);
|
||||
expect(item.smartCollectionName).toBe('Smarts');
|
||||
expect(item.watermarkIds).toEqual([1, 2]);
|
||||
expect(item.graphicsElementIds).toEqual([3]);
|
||||
expect(item.multipleMode).toBe('Count'); // null coalesced
|
||||
expect(item.tailMode).toBe('Offline');
|
||||
expect(item.playoutDuration).toBe('01:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeForSave', () => {
|
||||
it('nulls startTime + fixedStartTimeBehavior when not Fixed', () => {
|
||||
const req = normalizeForSave(draft({ startType: 'Dynamic', startTime: '06:00:00', fixedStartTimeBehavior: 'Strict' }));
|
||||
expect(req.startTime).toBeNull();
|
||||
expect(req.fixedStartTimeBehavior).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps startTime + behavior when Fixed', () => {
|
||||
const req = normalizeForSave(draft({ startType: 'Fixed', startTime: '06:00:00', fixedStartTimeBehavior: 'Flexible' }));
|
||||
expect(req.startTime).toBe('06:00:00');
|
||||
expect(req.fixedStartTimeBehavior).toBe('Flexible');
|
||||
});
|
||||
|
||||
it('nulls multipleCount unless Multiple+Count', () => {
|
||||
expect(normalizeForSave(draft({ playoutMode: 'One', multipleCount: '5' })).multipleCount).toBeNull();
|
||||
expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize', multipleCount: '5' })).multipleCount).toBeNull();
|
||||
expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '5' })).multipleCount).toBe('5');
|
||||
});
|
||||
|
||||
it('nulls duration fields (tailMode -> None) unless Duration', () => {
|
||||
const req = normalizeForSave(draft({ playoutMode: 'One', playoutDuration: '01:00:00', tailMode: 'Offline', discardToFillAttempts: 3 }));
|
||||
expect(req.playoutDuration).toBeNull();
|
||||
expect(req.tailMode).toBe('None');
|
||||
expect(req.discardToFillAttempts).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps duration fields when Duration', () => {
|
||||
const req = normalizeForSave(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', tailMode: 'Filler', discardToFillAttempts: 3 }));
|
||||
expect(req.playoutDuration).toBe('01:00:00');
|
||||
expect(req.tailMode).toBe('Filler');
|
||||
expect(req.discardToFillAttempts).toBe(3);
|
||||
});
|
||||
|
||||
it('resets marathon fields (except shuffleGroups) unless Marathon', () => {
|
||||
const req = normalizeForSave(draft({ playbackOrder: 'Shuffle', marathonGroupBy: 'Season', marathonShuffleItems: true, marathonShuffleGroups: true, marathonBatchSize: 4 }));
|
||||
expect(req.marathonGroupBy).toBe('None');
|
||||
expect(req.marathonShuffleItems).toBe(false);
|
||||
expect(req.marathonBatchSize).toBeNull();
|
||||
expect(req.marathonShuffleGroups).toBe(true);
|
||||
});
|
||||
|
||||
it('forces fillWithGroupMode None when ineligible', () => {
|
||||
const req = normalizeForSave(draft({ playoutMode: 'One', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' }));
|
||||
expect(req.fillWithGroupMode).toBe('None');
|
||||
});
|
||||
|
||||
it('does not emit local-only fields', () => {
|
||||
const req = normalizeForSave(draft());
|
||||
expect('_key' in req).toBe(false);
|
||||
expect('collectionName' in req).toBe(false);
|
||||
expect('name' in req).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('requires a start time for Fixed items', () => {
|
||||
expect(validateItem(draft({ startType: 'Fixed', startTime: null }))).toMatch(/start time/i);
|
||||
expect(validateItem(draft({ startType: 'Fixed', startTime: '06:00:00' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a count expression for Multiple+Count', () => {
|
||||
expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: null }))).toMatch(/count/i);
|
||||
expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '3' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('requires playoutDuration + non-negative discard for Duration', () => {
|
||||
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: null }))).toMatch(/duration/i);
|
||||
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: null }))).toMatch(/discard/i);
|
||||
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: -1 }))).toMatch(/discard/i);
|
||||
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('validateDraft prefixes the offending item index', () => {
|
||||
const items = [draft(), draft({ startType: 'Fixed', startTime: null })];
|
||||
expect(validateDraft(items)).toMatch(/^Item 2:/);
|
||||
expect(validateDraft([draft()])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
// Pure, exhaustively-tested rules for the schedule-item editor. Encodes the Blazor
|
||||
// ProgramScheduleItemEditViewModel gating + forced-reset behavior (the parity standard for #207).
|
||||
// No React, no fetch — every function is a deterministic transform so itemRules.test.ts can pin it.
|
||||
import type { components } from '../api/generated/v1';
|
||||
|
||||
export type CollectionType = components['schemas']['CollectionType'];
|
||||
export type PlaybackOrder = components['schemas']['PlaybackOrder'];
|
||||
export type PlayoutMode = components['schemas']['PlayoutMode'];
|
||||
export type MultipleMode = components['schemas']['MultipleMode'];
|
||||
export type StartType = components['schemas']['StartType'];
|
||||
export type TailMode = components['schemas']['TailMode'];
|
||||
export type FillWithGroupMode = components['schemas']['FillWithGroupMode'];
|
||||
export type MarathonGroupBy = components['schemas']['MarathonGroupBy'];
|
||||
export type GuideMode = components['schemas']['GuideMode'];
|
||||
export type FixedStartTimeBehavior = components['schemas']['FixedStartTimeBehavior'];
|
||||
export type SubtitleMode = components['schemas']['ChannelSubtitleMode'];
|
||||
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
|
||||
export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
|
||||
export type NamedId = components['schemas']['NamedIdResponseModel'];
|
||||
|
||||
// A local, editable schedule item. Carries every ScheduleItemRequest mutation field (named 1:1 with
|
||||
// the DTO) plus display-only hydration the search-based pickers need to render the current selection
|
||||
// without re-fetching, plus a stable local key for React + selection. `index` is intentionally NOT a
|
||||
// field — array order is the source of truth (server indexes from array position on PUT).
|
||||
export interface DraftItem extends ScheduleItemRequest {
|
||||
_key: string;
|
||||
collectionName: null | string;
|
||||
multiCollectionName: null | string;
|
||||
smartCollectionName: null | string;
|
||||
rerunCollectionName: null | string;
|
||||
playlistName: null | string;
|
||||
playlistGroupId: null | number;
|
||||
mediaItemName: null | string;
|
||||
name: null | string;
|
||||
durationEstimate: null | string;
|
||||
}
|
||||
|
||||
let keyCounter = 0;
|
||||
export function nextDraftKey(): string {
|
||||
keyCounter += 1;
|
||||
return `draft-${keyCounter}`;
|
||||
}
|
||||
|
||||
// ---- Option lists --------------------------------------------------------
|
||||
|
||||
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder'];
|
||||
const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
|
||||
const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle'];
|
||||
const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle'];
|
||||
const NONE_ORDERS: PlaybackOrder[] = ['None'];
|
||||
|
||||
// Never offered anywhere (Blazor parity): RandomRotation.
|
||||
export function playbackOrderOptions(collectionType: CollectionType): PlaybackOrder[] {
|
||||
switch (collectionType) {
|
||||
case 'MultiCollection':
|
||||
return MULTI_COLLECTION_ORDERS;
|
||||
case 'TelevisionShow':
|
||||
return TV_SHOW_ORDERS;
|
||||
case 'TelevisionSeason':
|
||||
case 'Artist':
|
||||
return SEASON_ARTIST_ORDERS;
|
||||
case 'Playlist':
|
||||
case 'RerunFirstRun':
|
||||
case 'RerunRerun':
|
||||
return NONE_ORDERS;
|
||||
case 'Collection':
|
||||
case 'SmartCollection':
|
||||
case 'SearchQuery':
|
||||
return COLLECTION_LIKE_ORDERS;
|
||||
default:
|
||||
return COLLECTION_LIKE_ORDERS;
|
||||
}
|
||||
}
|
||||
|
||||
// The playback-order selector is disabled entirely for Playlist / Rerun collection types.
|
||||
export function playbackOrderSelectDisabled(collectionType: CollectionType): boolean {
|
||||
return collectionType === 'Playlist' || collectionType === 'RerunFirstRun' || collectionType === 'RerunRerun';
|
||||
}
|
||||
|
||||
// The lone `None` option is labeled "Playlist" for a Playlist collection type (Blazor parity).
|
||||
export function playbackOrderLabel(order: PlaybackOrder, collectionType: CollectionType): string {
|
||||
if (order === 'None' && collectionType === 'Playlist') {
|
||||
return 'Playlist';
|
||||
}
|
||||
return humanizeEnum(order);
|
||||
}
|
||||
|
||||
export const MARATHON_GROUP_BY_OPTIONS: MarathonGroupBy[] = ['Album', 'Artist', 'Season', 'Show', 'Director'];
|
||||
export const TAIL_MODE_OPTIONS: TailMode[] = ['None', 'Offline', 'Filler'];
|
||||
export const GUIDE_MODE_OPTIONS: GuideMode[] = ['Normal', 'Filler'];
|
||||
export const FILL_WITH_GROUP_MODE_OPTIONS: FillWithGroupMode[] = ['None', 'FillWithOrderedGroups', 'FillWithShuffledGroups'];
|
||||
// subtitleMode is nullable ("Inherit" = null).
|
||||
export const SUBTITLE_MODE_OPTIONS: (SubtitleMode | null)[] = [null, 'None', 'Forced', 'Default', 'Any'];
|
||||
export const COLLECTION_TYPE_OPTIONS: CollectionType[] = [
|
||||
'Collection',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist',
|
||||
'MultiCollection',
|
||||
'SmartCollection',
|
||||
'Playlist',
|
||||
'RerunFirstRun',
|
||||
'RerunRerun',
|
||||
'SearchQuery'
|
||||
];
|
||||
|
||||
export function startTypeOptions(shuffleScheduleItems: boolean): StartType[] {
|
||||
return shuffleScheduleItems ? ['Dynamic'] : ['Dynamic', 'Fixed'];
|
||||
}
|
||||
|
||||
export function playoutModeOptions(shuffleScheduleItems: boolean): PlayoutMode[] {
|
||||
return shuffleScheduleItems ? ['One', 'Multiple', 'Duration'] : ['Flood', 'One', 'Multiple', 'Duration'];
|
||||
}
|
||||
|
||||
export function multipleModeOptions(collectionType: CollectionType, playbackOrder: PlaybackOrder): MultipleMode[] {
|
||||
const options: MultipleMode[] = ['Count'];
|
||||
options.push(collectionType === 'Playlist' ? 'PlaylistItemSize' : 'CollectionSize');
|
||||
if (playbackOrder === 'Chronological') {
|
||||
options.push('MultiEpisodeGroupSize');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ---- Enable gates (UI disables the control, save nulls the value) --------
|
||||
|
||||
export function startTimeEnabled(item: DraftItem): boolean {
|
||||
return item.startType === 'Fixed';
|
||||
}
|
||||
|
||||
export function fixedBehaviorEnabled(item: DraftItem): boolean {
|
||||
return item.startType === 'Fixed';
|
||||
}
|
||||
|
||||
export function multipleModeEnabled(item: DraftItem): boolean {
|
||||
return item.playoutMode === 'Multiple';
|
||||
}
|
||||
|
||||
export function multipleCountEnabled(item: DraftItem): boolean {
|
||||
return item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
|
||||
}
|
||||
|
||||
export function durationFieldsEnabled(item: DraftItem): boolean {
|
||||
return item.playoutMode === 'Duration';
|
||||
}
|
||||
|
||||
export function marathonVisible(item: DraftItem): boolean {
|
||||
return item.playbackOrder === 'Marathon';
|
||||
}
|
||||
|
||||
// Playlist collections show a "Shuffle Playlist Items" checkbox bound to marathonShuffleGroups
|
||||
// (dual-purpose column — Blazor parity).
|
||||
export function shufflePlaylistItemsVisible(item: DraftItem): boolean {
|
||||
return item.collectionType === 'Playlist';
|
||||
}
|
||||
|
||||
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder != ShuffleInOrder &&
|
||||
// collectionType in {Collection, MultiCollection, SmartCollection}.
|
||||
export function fillWithGroupModeEligible(item: DraftItem): boolean {
|
||||
const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration';
|
||||
const orderOk = item.playbackOrder !== 'ShuffleInOrder';
|
||||
const typeOk =
|
||||
item.collectionType === 'Collection' ||
|
||||
item.collectionType === 'MultiCollection' ||
|
||||
item.collectionType === 'SmartCollection';
|
||||
return modeOk && orderOk && typeOk;
|
||||
}
|
||||
|
||||
// ---- Forced-reset transforms --------------------------------------------
|
||||
|
||||
function enforceFillWithGroup(item: DraftItem): DraftItem {
|
||||
if (!fillWithGroupModeEligible(item) && item.fillWithGroupMode !== 'None') {
|
||||
return { ...item, fillWithGroupMode: 'None' };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
// Changing the collection type nulls every source ref (+ its display name) and re-seeds
|
||||
// multipleMode / playbackOrder per the Blazor rules, then reconciles fillWithGroup eligibility.
|
||||
export function applyCollectionTypeChange(item: DraftItem, newType: CollectionType): DraftItem {
|
||||
let next: DraftItem = {
|
||||
...item,
|
||||
collectionType: newType,
|
||||
collectionId: null,
|
||||
multiCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
rerunCollectionId: null,
|
||||
mediaItemId: null,
|
||||
playlistId: null,
|
||||
searchTitle: null,
|
||||
searchQuery: null,
|
||||
collectionName: null,
|
||||
multiCollectionName: null,
|
||||
smartCollectionName: null,
|
||||
rerunCollectionName: null,
|
||||
playlistName: null,
|
||||
playlistGroupId: null,
|
||||
mediaItemName: null
|
||||
};
|
||||
|
||||
// Reconcile playbackOrder into the set the new type offers. If the current order is not offered
|
||||
// (e.g. Marathon on a Collection → TelevisionShow, or any order → Playlist/Rerun which only offer
|
||||
// None), snap to the first offered order via applyPlaybackOrderChange so state matches what the
|
||||
// native <select> renders — a stale invalid order would otherwise display the first option while
|
||||
// state kept the old value (F2 / docs/decisions.md). applyPlaybackOrderChange also clears the now-
|
||||
// orphaned Marathon/MultiEpisode fields.
|
||||
const orderOptions = playbackOrderOptions(newType);
|
||||
if (!orderOptions.includes(next.playbackOrder)) {
|
||||
next = applyPlaybackOrderChange(next, orderOptions[0]);
|
||||
}
|
||||
|
||||
// Reconcile multipleMode into the valid set for the new (type, order) state — e.g. CollectionSize
|
||||
// is invalid after switching to Playlist (which offers PlaylistItemSize), and PlaylistItemSize is
|
||||
// invalid when leaving Playlist. Snap to the first valid mode when the current one is not offered.
|
||||
const modeOptions = multipleModeOptions(next.collectionType, next.playbackOrder);
|
||||
if (!modeOptions.includes(next.multipleMode)) {
|
||||
next = { ...next, multipleMode: modeOptions[0] };
|
||||
}
|
||||
|
||||
return enforceFillWithGroup(next);
|
||||
}
|
||||
|
||||
// Changing playback order resets MultiEpisodeGroupSize when not Chronological, resets the marathon
|
||||
// fields when not Marathon (marathonShuffleGroups is deliberately NOT reset — it is dual-purpose),
|
||||
// then reconciles fillWithGroup eligibility.
|
||||
export function applyPlaybackOrderChange(item: DraftItem, newOrder: PlaybackOrder): DraftItem {
|
||||
let next: DraftItem = { ...item, playbackOrder: newOrder };
|
||||
|
||||
if (newOrder !== 'Chronological' && next.multipleMode === 'MultiEpisodeGroupSize') {
|
||||
next = { ...next, multipleMode: 'Count' };
|
||||
}
|
||||
|
||||
if (newOrder !== 'Marathon') {
|
||||
next = {
|
||||
...next,
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleItems: false,
|
||||
marathonBatchSize: null
|
||||
};
|
||||
}
|
||||
|
||||
return enforceFillWithGroup(next);
|
||||
}
|
||||
|
||||
// A change to playoutMode can flip fillWithGroup eligibility (Multiple/Duration only).
|
||||
export function applyPlayoutModeChange(item: DraftItem, newMode: PlayoutMode): DraftItem {
|
||||
return enforceFillWithGroup({ ...item, playoutMode: newMode });
|
||||
}
|
||||
|
||||
// ---- Factories -----------------------------------------------------------
|
||||
|
||||
// Add-item defaults: startType Dynamic, playoutMode One, playbackOrder Shuffle,
|
||||
// collectionType Collection, empty watermarks/graphics.
|
||||
export function newDraftItem(): DraftItem {
|
||||
return {
|
||||
_key: nextDraftKey(),
|
||||
startType: 'Dynamic',
|
||||
startTime: null,
|
||||
fixedStartTimeBehavior: null,
|
||||
playoutMode: 'One',
|
||||
collectionType: 'Collection',
|
||||
collectionId: null,
|
||||
multiCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
rerunCollectionId: null,
|
||||
mediaItemId: null,
|
||||
playlistId: null,
|
||||
searchTitle: null,
|
||||
searchQuery: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
marathonBatchSize: null,
|
||||
fillWithGroupMode: 'None',
|
||||
multipleMode: 'Count',
|
||||
multipleCount: null,
|
||||
playoutDuration: null,
|
||||
tailMode: 'None',
|
||||
discardToFillAttempts: null,
|
||||
customTitle: null,
|
||||
guideMode: 'Normal',
|
||||
preRollFillerId: null,
|
||||
midRollFillerId: null,
|
||||
postRollFillerId: null,
|
||||
tailFillerId: null,
|
||||
fallbackFillerId: null,
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: [],
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: null,
|
||||
collectionName: null,
|
||||
multiCollectionName: null,
|
||||
smartCollectionName: null,
|
||||
rerunCollectionName: null,
|
||||
playlistName: null,
|
||||
playlistGroupId: null,
|
||||
mediaItemName: null,
|
||||
name: null,
|
||||
durationEstimate: null
|
||||
};
|
||||
}
|
||||
|
||||
// Deep copy of ALL fields, including the multi/smart/rerun collection references (+ names) that the
|
||||
// Blazor CopyItem omitted — deliberate bug fix, see docs/decisions.md 2026-07-11.
|
||||
export function copyDraftItem(item: DraftItem): DraftItem {
|
||||
return {
|
||||
...item,
|
||||
_key: nextDraftKey(),
|
||||
watermarkIds: [...(item.watermarkIds ?? [])],
|
||||
graphicsElementIds: [...(item.graphicsElementIds ?? [])]
|
||||
};
|
||||
}
|
||||
|
||||
// Maps a loaded GET response item into an editable draft (straight field copy — the DTO's mutation
|
||||
// fields are named 1:1 with the request).
|
||||
export function fromResponse(model: ScheduleItem): DraftItem {
|
||||
return {
|
||||
_key: nextDraftKey(),
|
||||
startType: model.startType,
|
||||
startTime: model.startTime,
|
||||
fixedStartTimeBehavior: model.fixedStartTimeBehavior,
|
||||
playoutMode: model.playoutMode,
|
||||
collectionType: model.collectionType,
|
||||
collectionId: model.collectionId,
|
||||
multiCollectionId: model.multiCollectionId,
|
||||
smartCollectionId: model.smartCollectionId,
|
||||
rerunCollectionId: model.rerunCollectionId,
|
||||
mediaItemId: model.mediaItemId,
|
||||
playlistId: model.playlistId,
|
||||
searchTitle: model.searchTitle,
|
||||
searchQuery: model.searchQuery,
|
||||
playbackOrder: model.playbackOrder,
|
||||
marathonGroupBy: model.marathonGroupBy,
|
||||
marathonShuffleGroups: model.marathonShuffleGroups,
|
||||
marathonShuffleItems: model.marathonShuffleItems,
|
||||
marathonBatchSize: model.marathonBatchSize,
|
||||
fillWithGroupMode: model.fillWithGroupMode,
|
||||
multipleMode: model.multipleMode ?? 'Count',
|
||||
multipleCount: model.multipleCount,
|
||||
playoutDuration: model.playoutDuration,
|
||||
tailMode: model.tailMode ?? 'None',
|
||||
discardToFillAttempts: model.discardToFillAttempts,
|
||||
customTitle: model.customTitle,
|
||||
guideMode: model.guideMode,
|
||||
preRollFillerId: model.preRollFillerId,
|
||||
midRollFillerId: model.midRollFillerId,
|
||||
postRollFillerId: model.postRollFillerId,
|
||||
tailFillerId: model.tailFillerId,
|
||||
fallbackFillerId: model.fallbackFillerId,
|
||||
watermarkIds: (model.watermarks ?? []).map((w) => w.id),
|
||||
graphicsElementIds: (model.graphicsElements ?? []).map((g) => g.id),
|
||||
preferredAudioLanguageCode: model.preferredAudioLanguageCode,
|
||||
preferredAudioTitle: model.preferredAudioTitle,
|
||||
preferredSubtitleLanguageCode: model.preferredSubtitleLanguageCode,
|
||||
subtitleMode: model.subtitleMode,
|
||||
collectionName: model.collectionName,
|
||||
multiCollectionName: model.multiCollectionName,
|
||||
smartCollectionName: model.smartCollectionName,
|
||||
rerunCollectionName: model.rerunCollectionName,
|
||||
playlistName: model.playlistName,
|
||||
playlistGroupId: model.playlistGroupId,
|
||||
mediaItemName: model.mediaItemName,
|
||||
name: model.name,
|
||||
durationEstimate: model.durationEstimate
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Save projection -----------------------------------------------------
|
||||
|
||||
// Projects a draft to the request body, applying Blazor's getter-gating: gated-off values are
|
||||
// nulled (or defaulted to the enum's None) so the payload matches what a non-editing Blazor VM emits.
|
||||
export function normalizeForSave(item: DraftItem): ScheduleItemRequest {
|
||||
const isFixed = item.startType === 'Fixed';
|
||||
const isMultipleCount = item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
|
||||
const isDuration = item.playoutMode === 'Duration';
|
||||
const isMarathon = item.playbackOrder === 'Marathon';
|
||||
const fillWithGroupMode = fillWithGroupModeEligible(item) ? item.fillWithGroupMode : 'None';
|
||||
|
||||
return {
|
||||
startType: item.startType,
|
||||
startTime: isFixed ? item.startTime : null,
|
||||
fixedStartTimeBehavior: isFixed ? item.fixedStartTimeBehavior : null,
|
||||
playoutMode: item.playoutMode,
|
||||
collectionType: item.collectionType,
|
||||
collectionId: item.collectionId,
|
||||
multiCollectionId: item.multiCollectionId,
|
||||
smartCollectionId: item.smartCollectionId,
|
||||
rerunCollectionId: item.rerunCollectionId,
|
||||
mediaItemId: item.mediaItemId,
|
||||
playlistId: item.playlistId,
|
||||
searchTitle: item.searchTitle,
|
||||
searchQuery: item.searchQuery,
|
||||
playbackOrder: item.playbackOrder,
|
||||
marathonGroupBy: isMarathon ? item.marathonGroupBy : 'None',
|
||||
marathonShuffleGroups: item.marathonShuffleGroups,
|
||||
marathonShuffleItems: isMarathon ? item.marathonShuffleItems : false,
|
||||
marathonBatchSize: isMarathon ? item.marathonBatchSize : null,
|
||||
fillWithGroupMode,
|
||||
multipleMode: item.multipleMode,
|
||||
multipleCount: isMultipleCount ? item.multipleCount : null,
|
||||
playoutDuration: isDuration ? item.playoutDuration : null,
|
||||
tailMode: isDuration ? item.tailMode : 'None',
|
||||
discardToFillAttempts: isDuration ? item.discardToFillAttempts : null,
|
||||
customTitle: item.customTitle,
|
||||
guideMode: item.guideMode,
|
||||
preRollFillerId: item.preRollFillerId,
|
||||
midRollFillerId: item.midRollFillerId,
|
||||
postRollFillerId: item.postRollFillerId,
|
||||
tailFillerId: item.tailFillerId,
|
||||
fallbackFillerId: item.fallbackFillerId,
|
||||
watermarkIds: item.watermarkIds ?? [],
|
||||
graphicsElementIds: item.graphicsElementIds ?? [],
|
||||
preferredAudioLanguageCode: item.preferredAudioLanguageCode,
|
||||
preferredAudioTitle: item.preferredAudioTitle,
|
||||
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode,
|
||||
subtitleMode: item.subtitleMode
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Client-side validation (mirrors the server) -------------------------
|
||||
|
||||
// Returns a human message for the first blocking problem, or null when the item is submittable.
|
||||
export function validateItem(item: DraftItem): null | string {
|
||||
if (item.startType === 'Fixed' && !item.startTime) {
|
||||
return 'Fixed start items require a start time';
|
||||
}
|
||||
if (item.playoutMode === 'Multiple' && item.multipleMode === 'Count' && !item.multipleCount) {
|
||||
return 'Multiple / Count items require a count expression';
|
||||
}
|
||||
if (item.playoutMode === 'Duration') {
|
||||
if (!item.playoutDuration) {
|
||||
return 'Duration items require a playout duration';
|
||||
}
|
||||
if (item.discardToFillAttempts == null || item.discardToFillAttempts < 0) {
|
||||
return 'Duration items require discard-to-fill attempts (0 or more)';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateDraft(items: DraftItem[]): null | string {
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const message = validateItem(items[i]);
|
||||
if (message) {
|
||||
return `Item ${i + 1}: ${message}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Display helpers -----------------------------------------------------
|
||||
|
||||
export function humanizeEnum(value: string): string {
|
||||
return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
export function draftItemLabel(item: DraftItem): string {
|
||||
return (
|
||||
item.name ??
|
||||
item.collectionName ??
|
||||
item.multiCollectionName ??
|
||||
item.smartCollectionName ??
|
||||
item.rerunCollectionName ??
|
||||
item.playlistName ??
|
||||
item.mediaItemName ??
|
||||
item.searchTitle ??
|
||||
item.searchQuery ??
|
||||
'Unnamed item'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Search, X } from 'lucide-react';
|
||||
import { Spinner } from '../components';
|
||||
import type { SchedulingPickerOption } from '../api';
|
||||
|
||||
// A debounced async autocomplete for the search-backed source pickers (shows/seasons/artists and
|
||||
// collection/multi/smart searches). Renders the current selection (label) with a clear button, an
|
||||
// input that queries `search(query)` after a 250ms debounce, and a results list.
|
||||
export interface SearchPickerProps {
|
||||
label: string;
|
||||
selectedId: null | number;
|
||||
selectedName: null | string;
|
||||
search: (query: string) => Promise<SchedulingPickerOption[]>;
|
||||
onSelect: (id: number, name: string) => void;
|
||||
onClear: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function SearchPicker({
|
||||
label,
|
||||
selectedId,
|
||||
selectedName,
|
||||
search,
|
||||
onSelect,
|
||||
onClear,
|
||||
disabled = false,
|
||||
placeholder = 'Search…'
|
||||
}: SearchPickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [results, setResults] = useState<SchedulingPickerOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const seqRef = useRef(0);
|
||||
|
||||
// All state transitions happen inside the debounce callback (never synchronously in the effect
|
||||
// body — see spa-conventions.md §3).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const trimmed = query.trim();
|
||||
const seq = ++seqRef.current;
|
||||
const handle = window.setTimeout(() => {
|
||||
if (trimmed.length === 0) {
|
||||
if (seqRef.current === seq) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
search(trimmed)
|
||||
.then((items) => {
|
||||
if (seqRef.current === seq) {
|
||||
setResults(items);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (seqRef.current === seq) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, trimmed.length === 0 ? 0 : 250);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, open, search]);
|
||||
|
||||
return (
|
||||
<div className="ctv-field ctv-field-full">
|
||||
<span className="ctv-field-label">{label}</span>
|
||||
{selectedId != null && !open ? (
|
||||
<div className="ctv-picker-selected">
|
||||
<span>{selectedName ?? `#${selectedId}`}</span>
|
||||
{!disabled && (
|
||||
<button type="button" aria-label={`Clear ${label}`} className="ctv-picker-clear" onClick={onClear}>
|
||||
<X aria-hidden="true" size={14} />
|
||||
</button>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button type="button" className="ctv-picker-change" onClick={() => setOpen(true)}>
|
||||
Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ctv-picker">
|
||||
<span className="ctv-field-frame ctv-field-frame-sm">
|
||||
<span className="ctv-field-icon"><Search aria-hidden="true" size={14} /></span>
|
||||
<input
|
||||
className="ctv-input"
|
||||
type="search"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-label={`${label} search`}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{loading && <span className="ctv-field-trailing"><Spinner size={13} tone="muted" /></span>}
|
||||
</span>
|
||||
{open && results.length > 0 && (
|
||||
<ul className="ctv-picker-results" role="listbox" aria-label={`${label} results`}>
|
||||
{results.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.id === selectedId}
|
||||
className="ctv-picker-result"
|
||||
onClick={() => {
|
||||
onSelect(option.id, option.name);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MultiSelectOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// A clearable multi-select rendered as a toggle list (watermarks + graphics elements). Emits the
|
||||
// full next id array on each toggle.
|
||||
export interface MultiSelectProps {
|
||||
label: string;
|
||||
options: MultiSelectOption[];
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
label,
|
||||
options,
|
||||
selectedIds,
|
||||
onChange,
|
||||
disabled = false,
|
||||
emptyLabel = 'None available'
|
||||
}: MultiSelectProps) {
|
||||
const toggle = (id: number) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onChange(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-field ctv-field-full">
|
||||
<span className="ctv-field-label">{label}</span>
|
||||
{options.length === 0 ? (
|
||||
<div className="ctv-picker-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
<div className="ctv-multiselect" role="group" aria-label={label}>
|
||||
{options.map((option) => {
|
||||
const active = selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
className={`ctv-multiselect-chip${active ? ' ctv-multiselect-chip-active' : ''}`}
|
||||
onClick={() => toggle(option.id)}
|
||||
>
|
||||
{active && <Check aria-hidden="true" size={12} />}
|
||||
{option.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,23 +50,27 @@ function json(body: unknown, status = 200): Response {
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
channelOverrides?: Record<string, unknown>;
|
||||
channelStatus?: number;
|
||||
onPut?: (body: unknown) => void;
|
||||
putResponseOverrides?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
|
||||
function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) {
|
||||
const loadedChannel = { ...channel, ...channelOverrides };
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/channels/5' && method === 'PUT') {
|
||||
onPut?.(JSON.parse(init?.body as string));
|
||||
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
|
||||
return Promise.resolve(json({ ...loadedChannel, name: 'Renamed', ...putResponseOverrides }));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/5') {
|
||||
return channelStatus === 200
|
||||
? Promise.resolve(json(channel))
|
||||
? Promise.resolve(json(loadedChannel))
|
||||
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
|
||||
}
|
||||
|
||||
@@ -86,6 +90,18 @@ function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/languages') {
|
||||
return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/music-video-credits-templates') {
|
||||
return Promise.resolve(json(['default']));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/stream-selectors') {
|
||||
return Promise.resolve(json(['selector.py']));
|
||||
}
|
||||
|
||||
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
});
|
||||
}
|
||||
@@ -158,4 +174,165 @@ describe('ChannelEditScreen', () => {
|
||||
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('external logo URL (Blazor ChannelEditor parity)', () => {
|
||||
it('hydrates the URL field from an external-URL logo and treats the uploaded logo as empty', async () => {
|
||||
mockApi({
|
||||
channelOverrides: { logo: { path: 'https://example.com/old-logo.png', contentType: '', isExternalUrl: true } }
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
expect(await screen.findByDisplayValue('https://example.com/old-logo.png')).toBeInTheDocument();
|
||||
// The preview shows the external URL, not an uploaded-logo path.
|
||||
expect(screen.getByRole('img', { name: 'Cartoons' })).toHaveAttribute('src', 'https://example.com/old-logo.png');
|
||||
});
|
||||
|
||||
it('sends an external URL as the logo, winning over any previously uploaded logo, on save', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({
|
||||
channelOverrides: { logo: { path: 'iptv/logos/uploaded.png', contentType: 'image/png' } },
|
||||
onPut: (body) => puts.push(body)
|
||||
});
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
|
||||
expect(puts[0]).toMatchObject({
|
||||
logo: { path: 'https://example.com/new-logo.png', contentType: '' }
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an inline error and blocks save for a non-http(s) URL', async () => {
|
||||
mockApi();
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
|
||||
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
|
||||
|
||||
expect(await screen.findByText('Enter a valid http(s) URL')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('clears the external URL field when a file is uploaded', async () => {
|
||||
mockApi();
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/artwork/uploads' && method === 'POST') {
|
||||
return Promise.resolve(json({ path: 'iptv/logos/new.png', contentType: 'image/png' }));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/5') {
|
||||
return Promise.resolve(json(channel));
|
||||
}
|
||||
|
||||
if (url === '/api/ffmpeg/profiles') {
|
||||
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/watermarks') {
|
||||
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/filler-presets') {
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/languages') {
|
||||
return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/music-video-credits-templates') {
|
||||
return Promise.resolve(json(['default']));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/stream-selectors') {
|
||||
return Promise.resolve(json(['selector.py']));
|
||||
}
|
||||
|
||||
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
});
|
||||
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
|
||||
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
|
||||
expect(urlInput).toHaveValue('https://example.com/new-logo.png');
|
||||
|
||||
const file = new File(['x'], 'logo.png', { type: 'image/png' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(urlInput).toHaveValue(''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('picker fields (languages, credits template, stream selector)', () => {
|
||||
it('keeps a preferred audio language selectable even when missing from the language list', async () => {
|
||||
mockApi({ channelOverrides: { preferredAudioLanguageCode: 'spa' } });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
|
||||
|
||||
const select = await screen.findByDisplayValue('spa');
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select.tagName).toBe('SELECT');
|
||||
});
|
||||
|
||||
it('populates the preferred audio language select from GET /api/languages', async () => {
|
||||
mockApi();
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
|
||||
|
||||
// Both the preferred-audio and preferred-subtitle language selects populate from the
|
||||
// same GET /api/languages list.
|
||||
expect((await screen.findAllByRole('option', { name: 'English' })).length).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a music video credits template selectable even when missing from the template list', async () => {
|
||||
mockApi({ channelOverrides: { musicVideoCreditsMode: 'GenerateSubtitles', musicVideoCreditsTemplate: 'legacy-template' } });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Music video/ }));
|
||||
|
||||
expect(await screen.findByDisplayValue('legacy-template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a custom stream selector selectable even when missing from the selector list', async () => {
|
||||
mockApi({ channelOverrides: { streamSelectorMode: 'Custom', streamSelector: 'legacy-selector.py' } });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
|
||||
|
||||
expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
ApiError,
|
||||
getChannelById,
|
||||
getChannels,
|
||||
getChannelStreamSelectors,
|
||||
getFFmpegProfiles,
|
||||
getFillerPresets,
|
||||
getLanguages,
|
||||
getMusicVideoCreditsTemplates,
|
||||
getWatermarks,
|
||||
messageFromError,
|
||||
updateChannel,
|
||||
@@ -27,6 +30,7 @@ import {
|
||||
type ChannelSummary,
|
||||
type FFmpegProfile,
|
||||
type FillerPreset,
|
||||
type LanguageCode,
|
||||
type UpdateChannelRequest,
|
||||
type Watermark
|
||||
} from '../api';
|
||||
@@ -111,6 +115,12 @@ function channelIdFromPathname(pathname: string): number | null {
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
// Blazor parity (ChannelEditor.razor + ChannelEditViewModel.ToUpdate): an external logo URL is
|
||||
// tracked as separate form state from the uploaded-logo ArtworkContentTypeModel, and wins over it
|
||||
// on submit. When hydrating a channel whose logo IS an external URL, the uploaded-logo state is
|
||||
// treated as empty so the two fields never disagree.
|
||||
const EMPTY_LOGO: NonNullable<UpdateChannelRequest['logo']> = { path: '', contentType: '' };
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
|
||||
// fields, so hydration is a straight field copy.
|
||||
function draftFromChannel(channel: Channel): UpdateChannelRequest {
|
||||
@@ -121,7 +131,7 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest {
|
||||
categories: channel.categories,
|
||||
ffmpegProfileId: channel.ffmpegProfileId,
|
||||
slugSeconds: channel.slugSeconds,
|
||||
logo: channel.logo,
|
||||
logo: channel.logo?.isExternalUrl ? EMPTY_LOGO : channel.logo,
|
||||
streamSelectorMode: channel.streamSelectorMode,
|
||||
streamSelector: channel.streamSelector,
|
||||
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
|
||||
@@ -149,6 +159,64 @@ function numberOrEmpty(value: null | number | undefined): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function externalLogoUrlFromChannel(channel: Channel): string {
|
||||
return channel.logo?.isExternalUrl ? (channel.logo.path ?? '') : '';
|
||||
}
|
||||
|
||||
// Blazor parity: the URL field only needs to look like http(s) when non-blank; blank means
|
||||
// "no external URL" and is always valid.
|
||||
function isValidExternalUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface StringOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Builds a "(none)" + list Select's options, keeping the channel's currently-stored value
|
||||
// selectable even if it's absent from the reference list (e.g. a language the media library
|
||||
// doesn't use, or a template/selector file that's been removed from disk since save) so loading
|
||||
// an existing channel never silently changes the value.
|
||||
function optionsKeepingCurrent(
|
||||
entries: StringOption[],
|
||||
current: null | string | undefined,
|
||||
noneLabel = '(none)'
|
||||
): StringOption[] {
|
||||
const options: StringOption[] = [{ label: noneLabel, value: '' }, ...entries];
|
||||
|
||||
if (current && !options.some((option) => option.value === current)) {
|
||||
options.push({ label: current, value: current });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function languageOptions(languages: LanguageCode[], current: null | string | undefined): StringOption[] {
|
||||
return optionsKeepingCurrent(
|
||||
languages.map((language) => ({ label: language.englishName ?? language.code ?? '', value: language.code ?? '' })),
|
||||
current
|
||||
);
|
||||
}
|
||||
|
||||
function stringListOptions(values: string[], current: null | string | undefined): StringOption[] {
|
||||
return optionsKeepingCurrent(
|
||||
values.map((value) => ({ label: value, value })),
|
||||
current
|
||||
);
|
||||
}
|
||||
|
||||
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
|
||||
return [...channels].sort((left, right) => {
|
||||
const leftNumber = Number.parseFloat(left.number);
|
||||
@@ -207,6 +275,9 @@ interface ReferenceData {
|
||||
fillerPresets: FillerPreset[];
|
||||
watermarks: Watermark[];
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
languages: LanguageCode[];
|
||||
musicVideoCreditsTemplates: string[];
|
||||
streamSelectors: string[];
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
@@ -431,9 +502,11 @@ function StreamingPane({
|
||||
}
|
||||
|
||||
function SelectionPane({
|
||||
data,
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
@@ -458,13 +531,12 @@ function SelectionPane({
|
||||
</Row>
|
||||
{isDefault ? (
|
||||
<>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
|
||||
<Input
|
||||
<Row help="Blank keeps the file order." label="Preferred audio language">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
options={languageOptions(data.languages, draft.preferredAudioLanguageCode)}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredAudioLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
@@ -476,13 +548,12 @@ function SelectionPane({
|
||||
value={draft.preferredAudioTitle ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
|
||||
<Input
|
||||
<Row help="Blank disables preference." label="Preferred subtitle language">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
options={languageOptions(data.languages, draft.preferredSubtitleLanguageCode)}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredSubtitleLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
@@ -503,11 +574,11 @@ function SelectionPane({
|
||||
</>
|
||||
) : (
|
||||
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
|
||||
<Input
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ streamSelector: event.target.value || null })}
|
||||
options={stringListOptions(data.streamSelectors, draft.streamSelector)}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.streamSelector ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
@@ -518,9 +589,11 @@ function SelectionPane({
|
||||
}
|
||||
|
||||
function MusicPane({
|
||||
data,
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
@@ -544,12 +617,12 @@ function MusicPane({
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
|
||||
<Input
|
||||
<Select
|
||||
disabled={!creditsOn}
|
||||
fullWidth
|
||||
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
|
||||
options={stringListOptions(data.musicVideoCreditsTemplates, draft.musicVideoCreditsTemplate)}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.musicVideoCreditsTemplate ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
@@ -573,24 +646,31 @@ function MusicPane({
|
||||
function BrandingPane({
|
||||
data,
|
||||
draft,
|
||||
externalLogoUrl,
|
||||
logoPreview,
|
||||
onChangeExternalLogoUrl,
|
||||
onUploadLogo,
|
||||
set,
|
||||
uploadError,
|
||||
uploading
|
||||
uploading,
|
||||
urlError
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
externalLogoUrl: string;
|
||||
logoPreview: string | null;
|
||||
onChangeExternalLogoUrl: (value: string) => void;
|
||||
onUploadLogo: (file: File) => void;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
uploadError: string | null;
|
||||
uploading: boolean;
|
||||
urlError: string | null;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
|
||||
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
|
||||
const previewSrc = logoPreview ?? existingLogo;
|
||||
const trimmedUrl = externalLogoUrl.trim();
|
||||
const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo);
|
||||
|
||||
return (
|
||||
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
|
||||
@@ -629,6 +709,20 @@ function BrandingPane({
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row
|
||||
control={340}
|
||||
help="An external image URL wins over an uploaded logo when both are set."
|
||||
label="External logo URL"
|
||||
>
|
||||
<Input
|
||||
error={urlError}
|
||||
fullWidth
|
||||
onChange={(event) => onChangeExternalLogoUrl(event.target.value)}
|
||||
placeholder="https://example.com/logo.png"
|
||||
size="sm"
|
||||
value={externalLogoUrl}
|
||||
/>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
@@ -681,6 +775,9 @@ export function ChannelEditScreen() {
|
||||
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
|
||||
const [section, setSection] = useState<SectionId>('general');
|
||||
|
||||
const [externalLogoUrl, setExternalLogoUrl] = useState('');
|
||||
const [savedExternalLogoUrl, setSavedExternalLogoUrl] = useState('');
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -696,18 +793,30 @@ export function ChannelEditScreen() {
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
|
||||
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
|
||||
Promise.all([
|
||||
getChannelById(channelId),
|
||||
getFFmpegProfiles(),
|
||||
getWatermarks(),
|
||||
getFillerPresets(),
|
||||
getChannels(),
|
||||
getLanguages(),
|
||||
getMusicVideoCreditsTemplates(),
|
||||
getChannelStreamSelectors()
|
||||
])
|
||||
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels, languages, musicVideoCreditsTemplates, streamSelectors]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = draftFromChannel(channel);
|
||||
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setExternalLogoUrl(initialExternalLogoUrl);
|
||||
setSavedExternalLogoUrl(initialExternalLogoUrl);
|
||||
setLoaded({
|
||||
channelId,
|
||||
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
|
||||
data: { channels, ffmpegProfiles, fillerPresets, languages, musicVideoCreditsTemplates, streamSelectors, watermarks },
|
||||
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
|
||||
});
|
||||
})
|
||||
@@ -770,6 +879,8 @@ export function ChannelEditScreen() {
|
||||
setDraft((current) =>
|
||||
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
|
||||
);
|
||||
// Blazor parity: uploading a file clears any external URL (the last-set field wins).
|
||||
setExternalLogoUrl('');
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
|
||||
@@ -781,13 +892,21 @@ export function ChannelEditScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const changeExternalLogoUrl = (value: string) => {
|
||||
setJustSaved(false);
|
||||
setExternalLogoUrl(value);
|
||||
};
|
||||
|
||||
const nameValid = Boolean(draft.name?.trim());
|
||||
const numberValid = Boolean(draft.number?.trim());
|
||||
const valid = nameValid && numberValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
const urlValid = isValidExternalUrl(externalLogoUrl);
|
||||
const valid = nameValid && numberValid && urlValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved) || externalLogoUrl !== savedExternalLogoUrl;
|
||||
const urlError = urlValid ? null : 'Enter a valid http(s) URL';
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
setExternalLogoUrl(savedExternalLogoUrl);
|
||||
setLogoPreview(null);
|
||||
setSaveError(null);
|
||||
};
|
||||
@@ -801,10 +920,18 @@ export function ChannelEditScreen() {
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const updated = await updateChannel(loaded.channelId, draft);
|
||||
const trimmedUrl = externalLogoUrl.trim();
|
||||
const body: UpdateChannelRequest = {
|
||||
...draft,
|
||||
logo: trimmedUrl ? { path: trimmedUrl, contentType: '' } : draft.logo
|
||||
};
|
||||
const updated = await updateChannel(loaded.channelId, body);
|
||||
const next = draftFromChannel(updated);
|
||||
const nextExternalLogoUrl = externalLogoUrlFromChannel(updated);
|
||||
setSaved(next);
|
||||
setDraft(next);
|
||||
setSavedExternalLogoUrl(nextExternalLogoUrl);
|
||||
setExternalLogoUrl(nextExternalLogoUrl);
|
||||
setLogoPreview(null);
|
||||
setJustSaved(true);
|
||||
window.setTimeout(() => setJustSaved(false), 1800);
|
||||
@@ -850,17 +977,20 @@ export function ChannelEditScreen() {
|
||||
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
|
||||
)}
|
||||
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
|
||||
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
|
||||
{section === 'music' && <MusicPane draft={draft} set={set} />}
|
||||
{section === 'selection' && <SelectionPane data={loaded.data} draft={draft} set={set} />}
|
||||
{section === 'music' && <MusicPane data={loaded.data} draft={draft} set={set} />}
|
||||
{section === 'branding' && (
|
||||
<BrandingPane
|
||||
data={loaded.data}
|
||||
draft={draft}
|
||||
externalLogoUrl={externalLogoUrl}
|
||||
logoPreview={logoPreview}
|
||||
onChangeExternalLogoUrl={changeExternalLogoUrl}
|
||||
onUploadLogo={(file) => void uploadLogo(file)}
|
||||
set={set}
|
||||
uploadError={uploadError}
|
||||
uploading={uploading}
|
||||
urlError={urlError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -876,7 +1006,8 @@ export function ChannelEditScreen() {
|
||||
) : (
|
||||
<>
|
||||
<span className="ctv-settings-savebar-text">Unsaved changes</span>
|
||||
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
|
||||
{!nameValid || !numberValid ? <Badge tone="neutral">Name and number are required</Badge> : null}
|
||||
{!urlValid && <Badge tone="neutral">Logo URL must be http(s)</Badge>}
|
||||
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
|
||||
<span className="ctv-settings-savebar-divider" />
|
||||
<Button onClick={discard} size="sm" variant="ghost">
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SchedulesScreen } from './SchedulesScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const schedule = {
|
||||
id: 1,
|
||||
name: 'Prime',
|
||||
keepMultiPartEpisodesTogether: false,
|
||||
treatCollectionsAsShows: false,
|
||||
shuffleScheduleItems: false,
|
||||
randomStartPoint: false,
|
||||
fixedStartTimeBehavior: 'Strict'
|
||||
};
|
||||
|
||||
function responseItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 10,
|
||||
index: 0,
|
||||
startType: 'Dynamic',
|
||||
startTime: null,
|
||||
fixedStartTimeBehavior: null,
|
||||
playoutMode: 'One',
|
||||
collectionType: 'Collection',
|
||||
collectionId: 5,
|
||||
multiCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
rerunCollectionId: null,
|
||||
mediaItemId: null,
|
||||
playlistId: null,
|
||||
searchTitle: null,
|
||||
searchQuery: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
marathonBatchSize: null,
|
||||
fillWithGroupMode: 'None',
|
||||
multipleMode: null,
|
||||
multipleCount: null,
|
||||
playoutDuration: null,
|
||||
tailMode: null,
|
||||
discardToFillAttempts: null,
|
||||
customTitle: null,
|
||||
guideMode: 'Normal',
|
||||
preRollFillerId: null,
|
||||
midRollFillerId: null,
|
||||
postRollFillerId: null,
|
||||
tailFillerId: null,
|
||||
fallbackFillerId: null,
|
||||
watermarkIds: [],
|
||||
graphicsElementIds: [],
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: null,
|
||||
collectionName: 'Comedies',
|
||||
multiCollectionName: null,
|
||||
smartCollectionName: null,
|
||||
rerunCollectionName: null,
|
||||
playlistName: null,
|
||||
playlistGroupId: null,
|
||||
mediaItemName: null,
|
||||
preRollFillerName: null,
|
||||
midRollFillerName: null,
|
||||
postRollFillerName: null,
|
||||
tailFillerName: null,
|
||||
fallbackFillerName: null,
|
||||
watermarks: [],
|
||||
graphicsElements: [],
|
||||
name: 'Comedies',
|
||||
durationEstimate: '00:30:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
interface MockOptions {
|
||||
schedules?: unknown[];
|
||||
items?: unknown[];
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
requests: Array<{ url: string; method: string; body: unknown }>;
|
||||
}
|
||||
|
||||
function mockApi(options: MockOptions = {}): MockHandle {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const schedules = options.schedules ?? [schedule];
|
||||
const items = options.items ?? [responseItem()];
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
if (options.onRequest) {
|
||||
const override = options.onRequest(url, method, body);
|
||||
if (override) {
|
||||
return Promise.resolve(override);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === '/api/schedules' && method === 'GET') return Promise.resolve(jsonResponse(schedules));
|
||||
if (url === '/api/schedules' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ ...schedule, id: 99, name: (body as { name: string }).name }, 201));
|
||||
}
|
||||
if (url.startsWith('/api/rerun-collections')) return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
|
||||
if (url === '/api/playlists/groups') return Promise.resolve(jsonResponse([]));
|
||||
if (url === '/api/watermarks') return Promise.resolve(jsonResponse([{ id: 7, name: 'Logo' }]));
|
||||
if (url === '/api/graphics-elements') return Promise.resolve(jsonResponse([]));
|
||||
if (url === '/api/languages') return Promise.resolve(jsonResponse([{ code: 'eng', englishName: 'English' }]));
|
||||
if (url.startsWith('/api/filler-presets')) return Promise.resolve(jsonResponse([]));
|
||||
if (url === '/api/schedules/1/items' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse({ items, totalDurationEstimate: '00:30:00' }));
|
||||
}
|
||||
if (url === '/api/schedules/1/items' && method === 'PUT') {
|
||||
// echo back the submitted items as response items (new ids)
|
||||
const submitted = (body as { items: Record<string, unknown>[] }).items;
|
||||
return Promise.resolve(jsonResponse(submitted.map((it, i) => responseItem({ ...it, id: 500 + i, index: i, name: `Item ${i}` }))));
|
||||
}
|
||||
if (url === '/api/schedules/99/items' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
|
||||
}
|
||||
if (/^\/api\/schedules\/\d+$/.test(url) && method === 'DELETE') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({}, 404));
|
||||
});
|
||||
|
||||
return { fetchSpy, requests };
|
||||
}
|
||||
|
||||
function lastPut(handle: MockHandle): Record<string, unknown>[] {
|
||||
const puts = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'PUT');
|
||||
return (puts[puts.length - 1].body as { items: Record<string, unknown>[] }).items;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
async function renderReady(options: MockOptions = {}) {
|
||||
const handle = mockApi(options);
|
||||
render(<SchedulesScreen />);
|
||||
await waitFor(() => expect(screen.getByLabelText('Schedule lineup').querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0));
|
||||
return handle;
|
||||
}
|
||||
|
||||
describe('SchedulesScreen — load', () => {
|
||||
it('renders the lineup for the active schedule', async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole('heading', { name: 'Prime' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('Schedule lineup')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fetches filler presets per kind (5 kinds)', async () => {
|
||||
const handle = await renderReady();
|
||||
const kinds = handle.requests.filter((r) => r.url.startsWith('/api/filler-presets')).map((r) => r.url);
|
||||
for (const kind of ['PreRoll', 'MidRoll', 'PostRoll', 'Tail', 'Fallback']) {
|
||||
expect(kinds.some((u) => u.includes(`fillerKind=${kind}`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// #229 Bug 2: array position becomes the persisted index on the next PUT, so a GET response whose rows
|
||||
// arrive out of `index` order must be sorted before ingest — otherwise re-saving silently reshuffles the
|
||||
// lineup. Rows are deliberately returned index-descending here.
|
||||
it('ingests items by their index even when the response rows are out of order', async () => {
|
||||
await renderReady({
|
||||
items: [
|
||||
responseItem({ id: 11, index: 1, collectionId: 6, collectionName: 'Dramas', name: 'Dramas' }),
|
||||
responseItem({ id: 10, index: 0, collectionId: 5, collectionName: 'Comedies', name: 'Comedies' })
|
||||
]
|
||||
});
|
||||
const lineup = screen.getByLabelText('Schedule lineup');
|
||||
// Each row exposes a "Move <name> down" control; their order reflects draft (== persisted index) order.
|
||||
const moveButtons = within(lineup)
|
||||
.getAllByRole('button', { name: /Move .+ down/ })
|
||||
.map((b) => b.getAttribute('aria-label'));
|
||||
expect(moveButtons[0]).toMatch(/Comedies/); // index 0 first, despite arriving second in the response
|
||||
expect(moveButtons[1]).toMatch(/Dramas/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — draft mutations issue a single PUT on Save', () => {
|
||||
it('add item then Save → PUT with two items, no other writes', async () => {
|
||||
const handle = await renderReady();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
const items = lastPut(handle);
|
||||
expect(items).toHaveLength(2);
|
||||
// exactly one PUT, and no POST/DELETE to items endpoints
|
||||
expect(handle.requests.filter((r) => r.method === 'PUT' && r.url === '/api/schedules/1/items')).toHaveLength(1);
|
||||
expect(handle.requests.filter((r) => r.url.includes('/items/'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('edit a field → PUT reflects it', async () => {
|
||||
const handle = await renderReady();
|
||||
const title = screen.getByLabelText('Custom title') as HTMLInputElement;
|
||||
fireEvent.change(title, { target: { value: 'Nightly' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
expect(lastPut(handle)[0].customTitle).toBe('Nightly');
|
||||
});
|
||||
|
||||
it('copy item deep-copies references into the PUT payload', async () => {
|
||||
const handle = await renderReady({
|
||||
items: [responseItem({ collectionType: 'MultiCollection', collectionId: null, multiCollectionId: 8, multiCollectionName: 'Multi', playbackOrder: 'Shuffle', name: 'Multi' })]
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy Multi/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
const items = lastPut(handle);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].multiCollectionId).toBe(8);
|
||||
expect(items[1].multiCollectionId).toBe(8);
|
||||
});
|
||||
|
||||
it('remove item → PUT omits it', async () => {
|
||||
const handle = await renderReady({ items: [responseItem({ id: 10, collectionId: 5, name: 'Comedies', collectionName: 'Comedies' }), responseItem({ id: 11, index: 1, collectionId: 6, name: 'Dramas', collectionName: 'Dramas' })] });
|
||||
const lineup = screen.getByLabelText('Schedule lineup');
|
||||
fireEvent.click(within(lineup).getByRole('button', { name: /Remove Comedies/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
const items = lastPut(handle);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].collectionId).toBe(6); // Dramas survived
|
||||
});
|
||||
|
||||
it('move item reorders the PUT payload', async () => {
|
||||
const handle = await renderReady({ items: [responseItem({ id: 10, collectionId: 5, name: 'Comedies', collectionName: 'Comedies' }), responseItem({ id: 11, index: 1, collectionId: 6, name: 'Dramas', collectionName: 'Dramas' })] });
|
||||
const lineup = screen.getByLabelText('Schedule lineup');
|
||||
fireEvent.click(within(lineup).getByRole('button', { name: /Move Comedies down/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
const items = lastPut(handle);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].collectionId).toBe(6); // Dramas moved up
|
||||
expect(items[1].collectionId).toBe(5); // Comedies moved down
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — error + dirty handling', () => {
|
||||
it('422 on Save keeps the draft dirty and shows the error', async () => {
|
||||
await renderReady({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/schedules/1/items' && method === 'PUT'
|
||||
? jsonResponse({ title: 'Invalid', detail: 'Name already used' }, 422)
|
||||
: null
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await screen.findByText('Name already used');
|
||||
// draft still has 2 items + Save still enabled (dirty)
|
||||
expect(within(screen.getByLabelText('Schedule lineup')).getAllByRole('listitem')).toHaveLength(2);
|
||||
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('guards a schedule switch when dirty (confirm=false aborts)', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ })); // make dirty
|
||||
const beforeGets = handle.requests.filter((r) => r.url.includes('/schedules/2/items')).length;
|
||||
fireEvent.change(screen.getByLabelText('Active schedule'), { target: { value: '2' } });
|
||||
// confirm returned false → no items fetch for schedule 2
|
||||
expect(handle.requests.filter((r) => r.url.includes('/schedules/2/items')).length).toBe(beforeGets);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — review fixes (F1/F3/F4/F5)', () => {
|
||||
it('F1: a failed items load after switching does not leave the prior draft saveable', async () => {
|
||||
const handle = await renderReady({
|
||||
schedules: [schedule, { ...schedule, id: 2, name: 'Late' }],
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/schedules/2/items' && method === 'GET'
|
||||
? jsonResponse({ title: 'Boom', detail: 'Load failed' }, 500)
|
||||
: null
|
||||
});
|
||||
// Make schedule 1 dirty (draft lineup differs from server).
|
||||
fireEvent.change(screen.getByLabelText('Custom title'), { target: { value: 'Nightly' } });
|
||||
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// Switch to schedule 2 (confirm mocked true); its items GET fails.
|
||||
fireEvent.change(screen.getByLabelText('Active schedule'), { target: { value: '2' } });
|
||||
await screen.findByText('Load failed');
|
||||
|
||||
// Header moved to schedule 2, but there is no stale schedule-1 lineup, Save is disabled, and no
|
||||
// PUT could ever be issued (which would have destructively replaced schedule 2 with A's draft).
|
||||
expect(screen.getByRole('heading', { name: 'Late' })).toBeTruthy();
|
||||
expect(screen.queryByLabelText('Schedule lineup')).toBeNull();
|
||||
expect(screen.getByText('No schedule items')).toBeTruthy();
|
||||
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(handle.requests.some((r) => r.url === '/api/schedules/2/items' && r.method === 'PUT')).toBe(false);
|
||||
});
|
||||
|
||||
it('F3: selecting a different item resets per-item picker state (playlist group)', async () => {
|
||||
await renderReady({
|
||||
items: [
|
||||
responseItem({ id: 10, index: 0, collectionType: 'Playlist', collectionId: null, collectionName: null, playbackOrder: 'None', playlistId: 100, playlistName: 'PL A', playlistGroupId: 1, name: 'PL A' }),
|
||||
responseItem({ id: 11, index: 1, collectionType: 'Playlist', collectionId: null, collectionName: null, playbackOrder: 'None', playlistId: 200, playlistName: 'PL B', playlistGroupId: 2, name: 'PL B' })
|
||||
],
|
||||
onRequest: (url) =>
|
||||
url === '/api/playlists/groups'
|
||||
? jsonResponse([{ id: 1, name: 'Group One' }, { id: 2, name: 'Group Two' }])
|
||||
: url.startsWith('/api/playlists?playlistGroupId=')
|
||||
? jsonResponse([])
|
||||
: null
|
||||
});
|
||||
const groupSelect = () => screen.getByLabelText('Playlist group') as HTMLSelectElement;
|
||||
// Item A is selected by default → PlaylistPicker seeded with group 1.
|
||||
expect(groupSelect().value).toBe('1');
|
||||
|
||||
// Select item B; with key={_key} the PlaylistPicker remounts and re-seeds groupId from item B.
|
||||
const lineup = screen.getByLabelText('Schedule lineup');
|
||||
fireEvent.click(within(lineup).getByText('PL B').closest('button') as HTMLButtonElement);
|
||||
expect(groupSelect().value).toBe('2');
|
||||
});
|
||||
|
||||
it('F4: edits during an in-flight Save are blocked (no lost update)', async () => {
|
||||
let resolvePut: (r: Response) => void = () => {};
|
||||
const putPromise = new Promise<Response>((res) => {
|
||||
resolvePut = res;
|
||||
});
|
||||
const handle = await renderReady({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/schedules/1/items' && method === 'PUT'
|
||||
? (putPromise as unknown as Response) // mockApi wraps in Promise.resolve, which adopts this thenable
|
||||
: null
|
||||
});
|
||||
const title = () => screen.getByLabelText('Custom title') as HTMLInputElement;
|
||||
fireEvent.change(title(), { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
|
||||
|
||||
// Attempt an edit while the Save is in flight — mutate() must no-op, so the input does NOT
|
||||
// optimistically show 'Second' (without the guard it would, then be silently clobbered by the
|
||||
// Save .then's setItems — the lost update). The PUT already sent 'First'.
|
||||
fireEvent.change(title(), { target: { value: 'Second' } });
|
||||
expect(title().value).toBe('First');
|
||||
|
||||
resolvePut(jsonResponse([responseItem({ id: 500, index: 0, customTitle: 'First', name: 'Item 0' })]));
|
||||
await waitFor(() => expect(title().value).toBe('First'));
|
||||
// The mid-save edit never produced a second PUT and never survived the reload.
|
||||
expect(handle.requests.filter((r) => r.method === 'PUT').length).toBe(1);
|
||||
});
|
||||
|
||||
it('F5: creating a schedule while the draft is dirty confirms before switching focus', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const handle = await renderReady();
|
||||
fireEvent.change(screen.getByLabelText('Custom title'), { target: { value: 'Nightly' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add schedule/ }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('Name'), { target: { value: 'Weekend' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules' && r.method === 'POST')).toBe(true));
|
||||
// The auto-switch to the new schedule went through the dirty guard; confirm=false aborted it.
|
||||
await waitFor(() => expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('unsaved')));
|
||||
expect(handle.requests.some((r) => r.url === '/api/schedules/99/items')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — schedule CRUD', () => {
|
||||
it('create flow POSTs and switches to the new schedule', async () => {
|
||||
const handle = await renderReady();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add schedule/ }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByLabelText('Name'), { target: { value: 'Weekend' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules' && r.method === 'POST')).toBe(true));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/99/items')).toBe(true));
|
||||
});
|
||||
|
||||
it('responds to the TopBar primary-action event by opening create', async () => {
|
||||
await renderReady();
|
||||
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: 'schedules' }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
|
||||
expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('delete flow DELETEs the active schedule', async () => {
|
||||
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Delete$/ }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Delete$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/1' && r.method === 'DELETE')).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — conditional rendering', () => {
|
||||
it('shows Marathon fields only for Marathon order', async () => {
|
||||
await renderReady({ items: [responseItem({ playbackOrder: 'Marathon', marathonGroupBy: 'Season' })] });
|
||||
expect(screen.getByLabelText('Marathon group by')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Duration fields only for Duration mode', async () => {
|
||||
await renderReady({ items: [responseItem({ playoutMode: 'Duration', playoutDuration: '01:00:00', tailMode: 'None', discardToFillAttempts: 0 })] });
|
||||
expect(screen.getByLabelText('Tail mode')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Discard to fill attempts')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the Fixed start type when the schedule shuffles items', async () => {
|
||||
await renderReady({ schedules: [{ ...schedule, shuffleScheduleItems: true }] });
|
||||
const startType = screen.getByLabelText('Start type') as HTMLSelectElement;
|
||||
const values = Array.from(startType.options).map((o) => o.value);
|
||||
expect(values).toEqual(['Dynamic']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,590 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CalendarClock, Copy, Pencil, Plus, RefreshCw, Save, Trash2, TriangleAlert, Undo2 } from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
IconButton,
|
||||
Select,
|
||||
Spinner
|
||||
} from '../components';
|
||||
import {
|
||||
deleteSchedule,
|
||||
getFillerPresetsByKind,
|
||||
getLanguages,
|
||||
getPlaylistGroups,
|
||||
getRerunCollections,
|
||||
getScheduleItems,
|
||||
getSchedules,
|
||||
getGraphicsElements,
|
||||
getWatermarks,
|
||||
messageFromScheduleError,
|
||||
type ProgramSchedule,
|
||||
replaceScheduleItems
|
||||
} from '../api';
|
||||
import { registerNavigationGuard } from '../navigationGuard';
|
||||
import {
|
||||
copyDraftItem,
|
||||
type DraftItem,
|
||||
draftItemLabel,
|
||||
fromResponse,
|
||||
humanizeEnum,
|
||||
newDraftItem,
|
||||
normalizeForSave,
|
||||
validateDraft
|
||||
} from '../schedules/itemRules';
|
||||
import { ScheduleForm } from '../schedules/ScheduleForm';
|
||||
import { ScheduleItemInspector, type SchedulePickerData } from '../schedules/ScheduleItemInspector';
|
||||
|
||||
const DIRTY_PROMPT = 'You have unsaved schedule changes. Discard them?';
|
||||
|
||||
type BootState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: string }
|
||||
| { status: 'ready'; schedules: ProgramSchedule[]; pickers: SchedulePickerData };
|
||||
|
||||
async function loadPickerData(): Promise<SchedulePickerData> {
|
||||
const [rerun, playlistGroups, watermarks, graphicsElements, languages, pre, mid, post, tail, fallback] =
|
||||
await Promise.all([
|
||||
getRerunCollections({ pageNum: 1, pageSize: 1000 }),
|
||||
getPlaylistGroups(),
|
||||
getWatermarks(),
|
||||
getGraphicsElements(),
|
||||
getLanguages(),
|
||||
getFillerPresetsByKind('PreRoll'),
|
||||
getFillerPresetsByKind('MidRoll'),
|
||||
getFillerPresetsByKind('PostRoll'),
|
||||
getFillerPresetsByKind('Tail'),
|
||||
getFillerPresetsByKind('Fallback')
|
||||
]);
|
||||
|
||||
return {
|
||||
rerunCollections: rerun.page ?? [],
|
||||
playlistGroups,
|
||||
watermarks,
|
||||
graphicsElements,
|
||||
languages,
|
||||
fillerPreRoll: pre,
|
||||
fillerMidRoll: mid,
|
||||
fillerPostRoll: post,
|
||||
fillerTail: tail,
|
||||
fillerFallback: fallback
|
||||
};
|
||||
}
|
||||
|
||||
export function SchedulesScreen() {
|
||||
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
const [items, setItems] = useState<DraftItem[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
// True only after a successful items GET for the CURRENT activeId. Gates every mutation surface so
|
||||
// a failed/in-flight load can't leave a stale draft editable (F1) — see guardedSwitch/applySwitch.
|
||||
const [itemsLoaded, setItemsLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<null | 'create' | 'edit'>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const activeRef = useRef(true);
|
||||
const dirtyRef = useRef(false);
|
||||
const baselineRef = useRef<DraftItem[]>([]);
|
||||
const itemSeq = useRef(0);
|
||||
|
||||
const setDirtyState = useCallback((value: boolean) => {
|
||||
dirtyRef.current = value;
|
||||
setDirty(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- Bootstrap load (schedules + pickers) ------------------------------
|
||||
// Only sets state in promise callbacks (never synchronously in the effect body — see
|
||||
// spa-conventions.md §3). The retry button flips to 'loading' itself before calling this.
|
||||
const loadBootstrap = useCallback(() => {
|
||||
Promise.all([getSchedules(), loadPickerData()])
|
||||
.then(([schedules, pickers]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setBoot({ status: 'ready', schedules, pickers });
|
||||
setActiveId((current) => current ?? schedules[0]?.id ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setBoot({ status: 'error', error: messageFromScheduleError(error, 'Unable to load schedules') });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadBootstrap();
|
||||
}, [loadBootstrap]);
|
||||
|
||||
// ---- Load items for the active schedule --------------------------------
|
||||
const loadItems = useCallback((scheduleId: number) => {
|
||||
const seq = ++itemSeq.current;
|
||||
getScheduleItems(scheduleId)
|
||||
.then((response) => {
|
||||
if (!activeRef.current || itemSeq.current !== seq) {
|
||||
return;
|
||||
}
|
||||
// Defensive: array position becomes the persisted index on the next PUT, so ingest strictly by the
|
||||
// server-provided `index` rather than trusting response row order (see #229 — the API now orders, but
|
||||
// the SPA must not silently reshuffle the lineup if that guarantee ever regresses).
|
||||
const drafts = [...(response.items ?? [])].sort((a, b) => a.index - b.index).map(fromResponse);
|
||||
baselineRef.current = drafts;
|
||||
setItems(drafts);
|
||||
setSelectedKey(drafts[0]?._key ?? null);
|
||||
setDirtyState(false);
|
||||
setMutationError(null);
|
||||
setItemsLoading(false);
|
||||
setItemsLoaded(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || itemSeq.current !== seq) {
|
||||
return;
|
||||
}
|
||||
setMutationError(messageFromScheduleError(error, 'Unable to load schedule items'));
|
||||
setItemsLoading(false);
|
||||
setItemsLoaded(false);
|
||||
});
|
||||
}, [setDirtyState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId != null) {
|
||||
loadItems(activeId);
|
||||
}
|
||||
}, [activeId, loadItems]);
|
||||
|
||||
// ---- Dirty guards ------------------------------------------------------
|
||||
useEffect(() => registerNavigationGuard(() => !dirtyRef.current || window.confirm(DIRTY_PROMPT)), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [dirty]);
|
||||
|
||||
// ---- TopBar "Add Schedule" primary action ------------------------------
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail === 'schedules') {
|
||||
setForm('create');
|
||||
}
|
||||
};
|
||||
window.addEventListener('ctv:primary-action', handler);
|
||||
return () => window.removeEventListener('ctv:primary-action', handler);
|
||||
}, []);
|
||||
|
||||
if (boot.status === 'loading') {
|
||||
return (
|
||||
<Card>
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner size={20} tone="accent" />
|
||||
<span>Loading schedules</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (boot.status === 'error') {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Schedules unavailable</h2>}
|
||||
subtitle="Live API request failed"
|
||||
actions={<Button onClick={() => { setBoot({ status: 'loading' }); loadBootstrap(); }} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-dashboard-error">
|
||||
<span>API request failed</span>
|
||||
<strong>{boot.error}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { schedules, pickers } = boot;
|
||||
const activeSchedule = schedules.find((s) => s.id === activeId) ?? null;
|
||||
const shuffleScheduleItems = activeSchedule?.shuffleScheduleItems ?? false;
|
||||
const selectedItem = items.find((i) => i._key === selectedKey) ?? items[0] ?? null;
|
||||
|
||||
// Editing is only permitted once the CURRENT schedule's items have loaded and while no Save is in
|
||||
// flight. Blocks the F1 destructive-cross-save (stale draft PUT into a different schedule) and the
|
||||
// F4 lost-update (edits made during a Save that the .then's setItems would silently discard).
|
||||
const canEdit = itemsLoaded && !saving;
|
||||
|
||||
// Synchronously drop the outgoing schedule's draft state BEFORE switching activeId, so a failed or
|
||||
// in-flight items GET for the new schedule can never leave the previous (possibly dirty) draft
|
||||
// visible/saveable (F1). The subsequent load re-arms itemsLoaded on success.
|
||||
const applySwitch = (scheduleId: number | null) => {
|
||||
setItems([]);
|
||||
baselineRef.current = [];
|
||||
setSelectedKey(null);
|
||||
setDirtyState(false);
|
||||
setMutationError(null);
|
||||
setItemsLoaded(false);
|
||||
setItemsLoading(scheduleId != null);
|
||||
setActiveId(scheduleId);
|
||||
};
|
||||
|
||||
const guardedSwitch = (scheduleId: number) => {
|
||||
if (saving || scheduleId === activeId) {
|
||||
return;
|
||||
}
|
||||
if (dirtyRef.current && !window.confirm(DIRTY_PROMPT)) {
|
||||
return;
|
||||
}
|
||||
applySwitch(scheduleId);
|
||||
};
|
||||
|
||||
const mutate = (nextItems: DraftItem[], nextSelected?: string | null) => {
|
||||
if (!canEdit) {
|
||||
return;
|
||||
}
|
||||
setItems(nextItems);
|
||||
setDirtyState(true);
|
||||
if (nextSelected !== undefined) {
|
||||
setSelectedKey(nextSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = (next: DraftItem) => {
|
||||
mutate(items.map((i) => (i._key === next._key ? next : i)));
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const item = newDraftItem();
|
||||
mutate([...items, item], item._key);
|
||||
};
|
||||
|
||||
const copyItem = (source: DraftItem) => {
|
||||
const copy = copyDraftItem(source);
|
||||
const index = items.findIndex((i) => i._key === source._key);
|
||||
const next = [...items];
|
||||
next.splice(index + 1, 0, copy);
|
||||
mutate(next, copy._key);
|
||||
};
|
||||
|
||||
const removeItem = (target: DraftItem) => {
|
||||
const index = items.findIndex((i) => i._key === target._key);
|
||||
const next = items.filter((i) => i._key !== target._key);
|
||||
const nextSelected = next[Math.min(index, next.length - 1)]?._key ?? null;
|
||||
mutate(next, nextSelected);
|
||||
};
|
||||
|
||||
const moveItem = (target: DraftItem, direction: -1 | 1) => {
|
||||
const index = items.findIndex((i) => i._key === target._key);
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(nextIndex, 0, moved);
|
||||
mutate(next);
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
if (!dirtyRef.current || window.confirm(DIRTY_PROMPT)) {
|
||||
const restored = baselineRef.current;
|
||||
setItems(restored);
|
||||
setSelectedKey(restored[0]?._key ?? null);
|
||||
setDirtyState(false);
|
||||
setMutationError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (activeId == null || saving || !itemsLoaded) {
|
||||
return;
|
||||
}
|
||||
const validationError = validateDraft(items);
|
||||
if (validationError) {
|
||||
setMutationError(validationError);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMutationError(null);
|
||||
replaceScheduleItems(activeId, { items: items.map(normalizeForSave) })
|
||||
.then((response) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
const drafts = [...response].sort((a, b) => a.index - b.index).map(fromResponse);
|
||||
baselineRef.current = drafts;
|
||||
setItems(drafts);
|
||||
setSelectedKey((current) => {
|
||||
const priorIndex = items.findIndex((i) => i._key === current);
|
||||
return drafts[priorIndex >= 0 ? priorIndex : 0]?._key ?? null;
|
||||
});
|
||||
setDirtyState(false);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items'));
|
||||
setSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onScheduleSaved = (saved: ProgramSchedule) => {
|
||||
setForm(null);
|
||||
setBoot((current) => {
|
||||
if (current.status !== 'ready') {
|
||||
return current;
|
||||
}
|
||||
const exists = current.schedules.some((s) => s.id === saved.id);
|
||||
const schedulesNext = exists
|
||||
? current.schedules.map((s) => (s.id === saved.id ? saved : s))
|
||||
: [...current.schedules, saved];
|
||||
return { ...current, schedules: schedulesNext };
|
||||
});
|
||||
if (!schedules.some((s) => s.id === saved.id)) {
|
||||
// New schedule: switch focus to it, but through the same dirty guard as the schedule picker so
|
||||
// an unsaved draft on the current schedule isn't silently wiped without a confirm (F5).
|
||||
guardedSwitch(saved.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSchedule = () => {
|
||||
if (activeId == null) {
|
||||
return;
|
||||
}
|
||||
deleteSchedule(activeId)
|
||||
.then(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setConfirmDelete(false);
|
||||
setBoot((current) => {
|
||||
if (current.status !== 'ready') {
|
||||
return current;
|
||||
}
|
||||
return { ...current, schedules: current.schedules.filter((s) => s.id !== activeId) };
|
||||
});
|
||||
const remaining = schedules.filter((s) => s.id !== activeId);
|
||||
// Drop the deleted schedule's draft and load the next one (or clear when none remain) — same
|
||||
// synchronous state reset as a picker switch so no stale draft survives (F1).
|
||||
applySwitch(remaining[0]?.id ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setMutationError(messageFromScheduleError(error, 'Unable to delete schedule'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const canReorder = !shuffleScheduleItems;
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-screen">
|
||||
<section className="ctv-schedule-header">
|
||||
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
|
||||
<div>
|
||||
<h2>{activeSchedule?.name ?? 'No schedule selected'}</h2>
|
||||
<p>
|
||||
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {items.length} item{items.length === 1 ? '' : 's'}
|
||||
{dirty && <> · <strong>unsaved changes</strong></>}
|
||||
</p>
|
||||
</div>
|
||||
{schedules.length > 0 && activeSchedule && (
|
||||
<Select
|
||||
label="Active schedule"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
value={`${activeSchedule.id}`}
|
||||
onChange={(e) => guardedSwitch(Number(e.target.value))}
|
||||
options={schedules.map((s) => ({ value: `${s.id}`, label: s.name ?? `Schedule ${s.id}` }))}
|
||||
/>
|
||||
)}
|
||||
<div className="ctv-schedule-header-actions">
|
||||
<Button size="sm" variant="ghost" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => setForm('create')}>Add schedule</Button>
|
||||
{activeSchedule && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" startIcon={<Pencil aria-hidden="true" size={14} />} onClick={() => setForm('edit')}>Edit</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Trash2 aria-hidden="true" size={14} />} onClick={() => setConfirmDelete(true)}>Delete</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{mutationError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{mutationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!activeSchedule ? (
|
||||
<Card title={<h2>No schedules</h2>} subtitle="Create a schedule to build a lineup.">
|
||||
<div className="ctv-schedule-empty">
|
||||
<Button startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => setForm('create')}>Add schedule</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="ctv-schedule-toolbar">
|
||||
<Button size="sm" variant="ghost" disabled={!canEdit} startIcon={<Plus aria-hidden="true" size={14} />} onClick={addItem}>Add item</Button>
|
||||
<div className="ctv-schedule-toolbar-spacer" />
|
||||
{itemsLoading && <Spinner size={14} tone="muted" />}
|
||||
<Button size="sm" variant="ghost" disabled={!dirty || saving} startIcon={<Undo2 aria-hidden="true" size={14} />} onClick={discard}>Discard</Button>
|
||||
<Button size="sm" disabled={!dirty || saving || !itemsLoaded} loading={saving} startIcon={<Save aria-hidden="true" size={14} />} onClick={save}>Save</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-schedule-grid">
|
||||
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
|
||||
<div className="ctv-schedule-panel-head">
|
||||
<span>Lineup{canReorder ? ' · use arrows to reorder' : ' · order fixed (shuffled schedule)'}</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="ctv-schedule-empty">No schedule items</div>
|
||||
) : (
|
||||
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
|
||||
{items.map((item, index) => (
|
||||
<LineupRow
|
||||
key={item._key}
|
||||
item={item}
|
||||
index={index}
|
||||
active={item._key === selectedItem?._key}
|
||||
canReorder={canReorder}
|
||||
disabled={!canEdit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
onSelect={() => setSelectedKey(item._key)}
|
||||
onCopy={() => copyItem(item)}
|
||||
onRemove={() => removeItem(item)}
|
||||
onMove={(dir) => moveItem(item, dir)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
|
||||
{selectedItem ? (
|
||||
<>
|
||||
<div className="ctv-schedule-inspector-head">
|
||||
<div>
|
||||
<strong>{draftItemLabel(selectedItem)}</strong>
|
||||
<span>{humanizeEnum(selectedItem.collectionType)}</span>
|
||||
</div>
|
||||
<IconButton title="Copy item" disabled={!canEdit} onClick={() => copyItem(selectedItem)}><Copy aria-hidden="true" size={15} /></IconButton>
|
||||
<IconButton title="Remove item" disabled={!canEdit} onClick={() => removeItem(selectedItem)}><Trash2 aria-hidden="true" size={15} /></IconButton>
|
||||
</div>
|
||||
<ScheduleItemInspector
|
||||
key={selectedItem._key}
|
||||
item={selectedItem}
|
||||
shuffleScheduleItems={shuffleScheduleItems}
|
||||
pickers={pickers}
|
||||
disabled={!canEdit}
|
||||
onChange={updateItem}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="ctv-schedule-empty">Select or add a schedule item</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<ScheduleForm
|
||||
mode={form}
|
||||
schedule={form === 'edit' ? activeSchedule : null}
|
||||
onSaved={onScheduleSaved}
|
||||
onClose={() => setForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
tone="danger"
|
||||
title="Delete schedule"
|
||||
message={`Delete "${activeSchedule?.name ?? ''}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={onDeleteSchedule}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineupRow({
|
||||
item,
|
||||
index,
|
||||
active,
|
||||
canReorder,
|
||||
disabled,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSelect,
|
||||
onCopy,
|
||||
onRemove,
|
||||
onMove
|
||||
}: {
|
||||
item: DraftItem;
|
||||
index: number;
|
||||
active: boolean;
|
||||
canReorder: boolean;
|
||||
disabled: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onSelect: () => void;
|
||||
onCopy: () => void;
|
||||
onRemove: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
}) {
|
||||
const label = draftItemLabel(item);
|
||||
const fixed = item.startType === 'Fixed';
|
||||
return (
|
||||
<div className="ctv-schedule-lineup-row" role="listitem">
|
||||
<div className="ctv-schedule-rail" aria-hidden="true">
|
||||
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
|
||||
<code>{fixed ? (item.startTime?.slice(0, 5) ?? '--:--') : 'flows'}</code>
|
||||
</div>
|
||||
<div className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}`}>
|
||||
<button type="button" className="ctv-schedule-block-main" aria-current={active ? 'true' : undefined} onClick={onSelect}>
|
||||
<span className="ctv-schedule-block-index">{index + 1}</span>
|
||||
<span className="ctv-schedule-block-title">
|
||||
<strong>{label}</strong>
|
||||
<small>{humanizeEnum(item.collectionType)} · {humanizeEnum(item.playoutMode)}</small>
|
||||
</span>
|
||||
</button>
|
||||
<div className="ctv-schedule-block-meta">
|
||||
<Badge tone="neutral">{humanizeEnum(item.playbackOrder)}</Badge>
|
||||
{item.guideMode === 'Filler' && <Badge tone="neutral">Hidden from guide</Badge>}
|
||||
</div>
|
||||
<div className="ctv-schedule-block-actions">
|
||||
{canReorder && (
|
||||
<>
|
||||
<IconButton size="sm" disabled={isFirst || disabled} title={`Move ${label} up`} onClick={() => onMove(-1)}>
|
||||
<span aria-hidden="true">↑</span>
|
||||
</IconButton>
|
||||
<IconButton size="sm" disabled={isLast || disabled} title={`Move ${label} down`} onClick={() => onMove(1)}>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton size="sm" disabled={disabled} title={`Copy ${label}`} onClick={onCopy}><Copy aria-hidden="true" size={14} /></IconButton>
|
||||
<IconButton size="sm" disabled={disabled} title={`Remove ${label}`} onClick={onRemove}><Trash2 aria-hidden="true" size={14} /></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,5 +167,7 @@ describe('TrashScreen', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
// Generous timeout: this test renders a 130-item grid twice; the CI VM has
|
||||
// hit the default 5s limit (run 686) while local runs pass comfortably.
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
@@ -1323,6 +1323,188 @@ body {
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
/* --- Schedules editor (#207) --------------------------------------------- */
|
||||
.ctv-schedule-header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-4, 8px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctv-schedule-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
padding: var(--space-4, 8px) var(--space-6, 12px);
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
border-radius: var(--radius-4, 10px);
|
||||
background: var(--surface-2, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.ctv-schedule-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--surface-3, rgba(255, 255, 255, 0.06));
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.ctv-schedule-fieldset {
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
border-radius: var(--radius-4, 10px);
|
||||
padding: var(--space-6, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctv-schedule-fieldset > legend {
|
||||
padding: 0 var(--space-4, 8px);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
.ctv-schedule-form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-field-help {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
/* Search + multi-select pickers */
|
||||
.ctv-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ctv-picker-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-3, 8px);
|
||||
background: var(--surface-2, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.ctv-picker-selected > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-picker-clear,
|
||||
.ctv-picker-change {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.6));
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ctv-picker-change:hover,
|
||||
.ctv-picker-clear:hover {
|
||||
color: var(--text-strong, #fff);
|
||||
background: var(--surface-3, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.ctv-picker-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-3, 8px);
|
||||
background: var(--surface-1, #1a1a1f);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.ctv-picker-result {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ctv-picker-result:hover,
|
||||
.ctv-picker-result[aria-selected='true'] {
|
||||
background: var(--surface-3, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.ctv-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.6));
|
||||
border: 1px dashed var(--border-subtle, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-3, 8px);
|
||||
}
|
||||
|
||||
.ctv-multiselect {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ctv-multiselect-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.14));
|
||||
background: var(--surface-2, rgba(255, 255, 255, 0.02));
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.7));
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctv-multiselect-chip-active {
|
||||
border-color: var(--accent, #6ea8fe);
|
||||
color: var(--text-strong, #fff);
|
||||
background: color-mix(in srgb, var(--accent, #6ea8fe) 18%, transparent);
|
||||
}
|
||||
|
||||
.ctv-multiselect-chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctv-schedule-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user