Merge pull request 'feat(web+api): playout alternate schedules + templates editors, default-deco read-side (#144 S6, #162)' (#179) from feat/144-s6-playout-editors into main
This commit was merged in pull request #179.
This commit is contained in:
@@ -54,7 +54,9 @@ public class
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus);
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -46,7 +46,9 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus);
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -49,7 +49,9 @@ public class
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus);
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -54,7 +54,9 @@ public class
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus);
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -15,7 +15,11 @@ internal static class Mapper
|
||||
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
|
||||
playout.ScheduleFile,
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus);
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
// the paged-playouts query does not eager-load Deco (the list response does not surface
|
||||
// the default deco); GetPlayoutById includes it for the detail response
|
||||
playout.Deco?.Name);
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
|
||||
@@ -11,7 +11,9 @@ public record PlayoutNameViewModel(
|
||||
string ScheduleName,
|
||||
string ScheduleFile,
|
||||
TimeSpan? DbDailyRebuildTime,
|
||||
PlayoutBuildStatus BuildStatus)
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.BuildStatus)
|
||||
.Include(p => p.Deco)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.MapT(p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
@@ -27,6 +28,8 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.ScheduleFile,
|
||||
p.DailyRebuildTime,
|
||||
p.BuildStatus));
|
||||
p.BuildStatus,
|
||||
p.DecoId,
|
||||
p.DecoId == null ? null : p.Deco.Name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutAlternateScheduleResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
ICollection<DayOfWeek> DaysOfWeek,
|
||||
ICollection<int> DaysOfMonth,
|
||||
ICollection<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
int? StartYear,
|
||||
int EndMonth,
|
||||
int EndDay,
|
||||
int? EndYear);
|
||||
@@ -12,7 +12,9 @@ public record PlayoutResponseModel(
|
||||
string ScheduleName,
|
||||
string? ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus)
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
int? DecoId,
|
||||
string? DecoName)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
@@ -23,7 +25,9 @@ public record PlayoutResponseModel(
|
||||
string scheduleName,
|
||||
string? scheduleFile,
|
||||
TimeSpan? dailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? buildStatus) =>
|
||||
PlayoutBuildStatusResponseModel? buildStatus,
|
||||
int? decoId,
|
||||
string? decoName) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
@@ -33,5 +37,7 @@ public record PlayoutResponseModel(
|
||||
scheduleName,
|
||||
scheduleFile,
|
||||
dailyRebuildTime,
|
||||
buildStatus);
|
||||
buildStatus,
|
||||
decoId,
|
||||
decoName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Scheduling;
|
||||
|
||||
public record PlayoutTemplateResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
int TemplateId,
|
||||
string TemplateName,
|
||||
string TemplateGroupName,
|
||||
int? DecoTemplateId,
|
||||
string? DecoTemplateName,
|
||||
string? DecoTemplateGroupName,
|
||||
ICollection<DayOfWeek> DaysOfWeek,
|
||||
ICollection<int> DaysOfMonth,
|
||||
ICollection<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
int? StartYear,
|
||||
int EndMonth,
|
||||
int EndDay,
|
||||
int? EndYear);
|
||||
@@ -121,6 +121,14 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
|
||||
|
||||
@@ -398,6 +398,8 @@ public class ChannelControllerTests
|
||||
"Schedule",
|
||||
string.Empty,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -43,6 +47,10 @@ public class PlayoutControllerTests
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/playouts/{id:int}/alternate-schedules");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/playouts/{id:int}/templates");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/playouts/{id:int}/templates");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -448,6 +456,437 @@ public class PlayoutControllerTests
|
||||
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Alternate schedules -----
|
||||
|
||||
[Test]
|
||||
public async Task GetAlternateSchedules_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetAlternateSchedules(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAlternateSchedules_Should_Return_422_For_NonClassic_Playout()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
|
||||
IActionResult result = await _controller.GetAlternateSchedules(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAlternateSchedules_Should_Project_Ordered_List()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(2, 1, 8), MakeAltVm(1, 0, 7)]);
|
||||
|
||||
IActionResult actionResult = await _controller.GetAlternateSchedules(9, CancellationToken.None);
|
||||
|
||||
var list = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PlayoutAlternateScheduleResponseModel>>();
|
||||
list.Count.ShouldBe(2);
|
||||
list[0].Index.ShouldBe(0);
|
||||
list[0].ProgramScheduleId.ShouldBe(7);
|
||||
list[1].Index.ShouldBe(1);
|
||||
list[1].ProgramScheduleId.ShouldBe(8);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
404,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_422_For_NonClassic_Playout()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
9,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_422_When_Items_Empty()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
9,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(0, 1, 12, 31)]
|
||||
[TestCase(13, 1, 12, 31)]
|
||||
[TestCase(1, 1, 0, 31)]
|
||||
[TestCase(1, 1, 13, 31)]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Month_Without_Dispatch(
|
||||
int startMonth,
|
||||
int startDay,
|
||||
int endMonth,
|
||||
int endDay)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(0, 1, 12, 31)]
|
||||
[TestCase(1, 32, 12, 31)]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Day_Without_Dispatch(
|
||||
int startMonth,
|
||||
int startDay,
|
||||
int endMonth,
|
||||
int endDay)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7)]);
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1)
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_422_When_ProgramScheduleId_Unknown()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
9,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(999)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Assign_Index_From_Order_And_Return_200()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7), MakeScheduleVm(8)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7), MakeAltVm(2, 1, 8)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
9,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(8)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
||||
c.PlayoutId == 9 &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].Index == 0 && c.Items[0].ProgramScheduleId == 7 &&
|
||||
c.Items[1].Index == 1 && c.Items[1].ProgramScheduleId == 8),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout templates -----
|
||||
|
||||
[Test]
|
||||
public async Task GetTemplates_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetTemplates(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetTemplates_Should_Return_422_For_NonBlock_Playout()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
IActionResult result = await _controller.GetTemplates(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetTemplates_Should_Project_Ordered_List()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateVm(2, 1, 8, 5), MakeTemplateVm(1, 0, 7, null)]);
|
||||
|
||||
IActionResult actionResult = await _controller.GetTemplates(9, CancellationToken.None);
|
||||
|
||||
var list = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PlayoutTemplateResponseModel>>();
|
||||
list.Count.ShouldBe(2);
|
||||
list[0].Index.ShouldBe(0);
|
||||
list[0].TemplateId.ShouldBe(7);
|
||||
list[0].DecoTemplateId.ShouldBeNull();
|
||||
list[1].Index.ShouldBe(1);
|
||||
list[1].TemplateId.ShouldBe(8);
|
||||
list[1].DecoTemplateId.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
404,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_422_For_NonBlock_Playout()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
9,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(0, 1, 12, 31)]
|
||||
[TestCase(13, 1, 12, 31)]
|
||||
[TestCase(1, 1, 0, 31)]
|
||||
[TestCase(1, 1, 13, 31)]
|
||||
[TestCase(1, 0, 12, 31)]
|
||||
[TestCase(1, 32, 12, 31)]
|
||||
public async Task ReplaceTemplates_Should_Return_422_For_OutOfRange_DateRange_Without_Dispatch(
|
||||
int startMonth,
|
||||
int startDay,
|
||||
int endMonth,
|
||||
int endDay)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateVm(1, 0, 7, null)]);
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_422_When_TemplateId_Unknown()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
9,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(999, null)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_422_When_DecoTemplateId_Unknown()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7)]);
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
9,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, 999)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Assign_Index_From_Order_And_Return_200()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]);
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT")));
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateVm(1, 0, 7, null), MakeTemplateVm(2, 1, 8, 5)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
9,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null), MakeTemplateRequest(8, 5)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutTemplateItems>(c =>
|
||||
c.PlayoutId == 9 &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].Index == 0 && c.Items[0].TemplateId == 7 && c.Items[0].DecoTemplateId == null &&
|
||||
c.Items[1].Index == 1 && c.Items[1].TemplateId == 8 && c.Items[1].DecoTemplateId == 5),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) =>
|
||||
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
|
||||
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict);
|
||||
|
||||
private static TemplateViewModel MakeTemplateViewModel(int id) =>
|
||||
new(id, 1, "Group", $"Template {id}");
|
||||
|
||||
private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) =>
|
||||
new(
|
||||
id,
|
||||
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}"),
|
||||
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}") : null,
|
||||
index,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null);
|
||||
|
||||
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
|
||||
new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id) =>
|
||||
new(
|
||||
id,
|
||||
@@ -458,7 +897,9 @@ public class PlayoutControllerTests
|
||||
"Schedule",
|
||||
string.Empty,
|
||||
null,
|
||||
new PlayoutBuildStatus());
|
||||
new PlayoutBuildStatus(),
|
||||
null,
|
||||
null);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
|
||||
PlayoutResponseModel.From(
|
||||
@@ -475,7 +916,9 @@ public class PlayoutControllerTests
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
vm.BuildStatus.LastBuild,
|
||||
vm.BuildStatus.Success,
|
||||
vm.BuildStatus.Message));
|
||||
vm.BuildStatus.Message),
|
||||
vm.DecoId,
|
||||
vm.DecoName);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
@@ -234,6 +236,200 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/alternate-schedules", Name = "GetPlayoutAlternateSchedules")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a classic playout's alternate schedules")]
|
||||
[EndpointDescription(
|
||||
"Lists a Classic playout's alternate schedules in priority order (first = highest priority). The last " +
|
||||
"entry is the catch-all default, and its schedule is the playout's default schedule; if no explicit " +
|
||||
"catch-all exists the query synthesizes one from the playout's default schedule. Only valid for Classic " +
|
||||
"playouts; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> GetAlternateSchedules(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not PlayoutScheduleKind.Classic)
|
||||
{
|
||||
return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> items =
|
||||
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}/alternate-schedules")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Replace a classic playout's alternate schedules")]
|
||||
[EndpointDescription(
|
||||
"Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the " +
|
||||
"highest priority and the last item is the catch-all default whose schedule becomes the playout's default " +
|
||||
"schedule. Index is assigned from array order (the request body has no Index field). The list must contain " +
|
||||
"at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceAlternateSchedules(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not PlayoutScheduleKind.Classic)
|
||||
{
|
||||
return BaseError.New("[AlternateSchedules] are only valid for Classic playouts").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlayoutAlternateScheduleItemRequest> items = request.Items ?? [];
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return BaseError.New("[Items] must contain at least one alternate schedule").ToErrorResult();
|
||||
}
|
||||
|
||||
foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck)))
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
List<ProgramScheduleViewModel> schedules =
|
||||
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
|
||||
var scheduleIds = schedules.Select(s => s.Id).ToHashSet();
|
||||
var missingScheduleIds = items.Select(i => i.ProgramScheduleId).Distinct()
|
||||
.Where(scheduleId => !scheduleIds.Contains(scheduleId)).ToList();
|
||||
if (missingScheduleIds.Count > 0)
|
||||
{
|
||||
return BaseError.New($"[ProgramScheduleId] {missingScheduleIds[0]} does not exist").ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
List<PlayoutAlternateScheduleViewModel> refreshed =
|
||||
await mediator.Send(new GetPlayoutAlternateSchedules(id), cancellationToken);
|
||||
return (IActionResult)new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/templates", Name = "GetPlayoutTemplates")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a block playout's templates")]
|
||||
[EndpointDescription(
|
||||
"Lists a Block playout's templates in priority order (first = highest priority). Each template optionally " +
|
||||
"carries a deco template. Only valid for Block playouts; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> GetTemplates(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not PlayoutScheduleKind.Block)
|
||||
{
|
||||
return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlayoutTemplateViewModel> items = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
|
||||
return new OkObjectResult(items.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/playouts/{id:int}/templates")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Replace a block playout's templates")]
|
||||
[EndpointDescription(
|
||||
"Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is " +
|
||||
"assigned from array order (the request body has no Index field). Every TemplateId must exist, and any " +
|
||||
"supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceTemplates(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not PlayoutScheduleKind.Block)
|
||||
{
|
||||
return BaseError.New("[Templates] are only valid for Block playouts").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlayoutTemplateItemRequest> items = request.Items ?? [];
|
||||
|
||||
foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck)))
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
List<TemplateViewModel> templates = await mediator.Send(new GetAllTemplates(), cancellationToken);
|
||||
var templateIds = templates.Select(t => t.Id).ToHashSet();
|
||||
var missingTemplateIds = items.Select(i => i.TemplateId).Distinct()
|
||||
.Where(templateId => !templateIds.Contains(templateId)).ToList();
|
||||
if (missingTemplateIds.Count > 0)
|
||||
{
|
||||
return BaseError.New($"[TemplateId] {missingTemplateIds[0]} does not exist").ToErrorResult();
|
||||
}
|
||||
|
||||
var decoTemplateIds = items.Where(i => i.DecoTemplateId.HasValue)
|
||||
.Select(i => i.DecoTemplateId!.Value).Distinct().ToList();
|
||||
foreach (int decoTemplateId in decoTemplateIds)
|
||||
{
|
||||
Option<DecoTemplateViewModel> maybeDecoTemplate =
|
||||
await mediator.Send(new GetDecoTemplateById(decoTemplateId), cancellationToken);
|
||||
if (maybeDecoTemplate.IsNone)
|
||||
{
|
||||
return BaseError.New($"[DecoTemplateId] {decoTemplateId} does not exist").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
foreach (BaseError error in result)
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
|
||||
List<PlayoutTemplateViewModel> refreshed = await mediator.Send(new GetPlayoutTemplates(id), cancellationToken);
|
||||
return new OkObjectResult(refreshed.OrderBy(i => i.Index).Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
@@ -258,6 +454,53 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
// Guards against AlternateScheduleSelector crashing on out-of-range dates: it constructs
|
||||
// `new DateTime(year, StartMonth, StartDay)` and only recovers from an overflowing *day*
|
||||
// (rolling to the 1st of the next month); an out-of-range *month* throws again from inside
|
||||
// that recovery path and is never caught. Only checked when LimitToDateRange is set, since
|
||||
// the fields are ignored otherwise.
|
||||
private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck(
|
||||
PlayoutAlternateScheduleItemRequest item) =>
|
||||
(item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay);
|
||||
|
||||
private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck(
|
||||
PlayoutTemplateItemRequest item) =>
|
||||
(item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay);
|
||||
|
||||
private static Option<BaseError> ValidateDateRanges(
|
||||
IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items)
|
||||
{
|
||||
foreach ((bool limitToDateRange, int startMonth, int startDay, int endMonth, int endDay) in items)
|
||||
{
|
||||
if (!limitToDateRange)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startMonth is < 1 or > 12)
|
||||
{
|
||||
return Some(BaseError.New($"[StartMonth] {startMonth} must be between 1 and 12"));
|
||||
}
|
||||
|
||||
if (endMonth is < 1 or > 12)
|
||||
{
|
||||
return Some(BaseError.New($"[EndMonth] {endMonth} must be between 1 and 12"));
|
||||
}
|
||||
|
||||
if (startDay is < 1 or > 31)
|
||||
{
|
||||
return Some(BaseError.New($"[StartDay] {startDay} must be between 1 and 31"));
|
||||
}
|
||||
|
||||
if (endDay is < 1 or > 31)
|
||||
{
|
||||
return Some(BaseError.New($"[EndDay] {endDay} must be between 1 and 31"));
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
|
||||
PlayoutResponseModel.From(
|
||||
vm.PlayoutId,
|
||||
@@ -268,7 +511,46 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.DecoId,
|
||||
vm.DecoName);
|
||||
|
||||
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Index,
|
||||
vm.ProgramScheduleId,
|
||||
vm.DaysOfWeek,
|
||||
vm.DaysOfMonth,
|
||||
vm.MonthsOfYear,
|
||||
vm.LimitToDateRange,
|
||||
vm.StartMonth,
|
||||
vm.StartDay,
|
||||
vm.StartYear,
|
||||
vm.EndMonth,
|
||||
vm.EndDay,
|
||||
vm.EndYear);
|
||||
|
||||
private static PlayoutTemplateResponseModel ToResponse(PlayoutTemplateViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Index,
|
||||
vm.Template.Id,
|
||||
vm.Template.Name,
|
||||
vm.Template.GroupName,
|
||||
vm.DecoTemplate?.Id,
|
||||
vm.DecoTemplate?.Name,
|
||||
vm.DecoTemplate?.GroupName,
|
||||
vm.DaysOfWeek,
|
||||
vm.DaysOfMonth,
|
||||
vm.MonthsOfYear,
|
||||
vm.LimitToDateRange,
|
||||
vm.StartMonth,
|
||||
vm.StartDay,
|
||||
vm.StartYear,
|
||||
vm.EndMonth,
|
||||
vm.EndDay,
|
||||
vm.EndYear);
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
new(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlayoutAlternateScheduleItemRequest(
|
||||
int Id,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
int? StartYear,
|
||||
int EndMonth,
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
ProgramScheduleId,
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
StartYear,
|
||||
EndMonth,
|
||||
EndDay,
|
||||
EndYear);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlayoutTemplateItemRequest(
|
||||
int Id,
|
||||
int TemplateId,
|
||||
int? DecoTemplateId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
int? StartYear,
|
||||
int EndMonth,
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
public ReplacePlayoutTemplate ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
TemplateId,
|
||||
DecoTemplateId,
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
StartYear,
|
||||
EndMonth,
|
||||
EndDay,
|
||||
EndYear);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplacePlayoutAlternateSchedulesRequest(List<PlayoutAlternateScheduleItemRequest> Items)
|
||||
{
|
||||
// Index is assigned from array order: the first item is the highest priority and the last item
|
||||
// is the lowest priority (the catch-all default whose schedule becomes the playout's default
|
||||
// schedule). This mirrors the Blazor editor, which lists items top-to-bottom in priority order
|
||||
// and writes the highest-Index item's schedule as the playout default.
|
||||
public ReplacePlayoutAlternateScheduleItems ToCommand(int playoutId) =>
|
||||
new(
|
||||
playoutId,
|
||||
(Items ?? [])
|
||||
.Select((item, index) => item.ToReplaceItem(index))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplacePlayoutTemplatesRequest(List<PlayoutTemplateItemRequest> Items)
|
||||
{
|
||||
// Index is assigned from array order (top-to-bottom priority), mirroring the Blazor editor.
|
||||
public ReplacePlayoutTemplateItems ToCommand(int playoutId) =>
|
||||
new(
|
||||
playoutId,
|
||||
(Items ?? [])
|
||||
.Select((item, index) => item.ToReplaceItem(index))
|
||||
.ToList());
|
||||
}
|
||||
@@ -5903,6 +5903,418 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/playouts/{id}/alternate-schedules": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Playouts"
|
||||
],
|
||||
"summary": "Get a classic playout's alternate schedules",
|
||||
"description": "Lists a Classic playout's alternate schedules in priority order (first = highest priority). The last entry is the catch-all default, and its schedule is the playout's default schedule; if no explicit catch-all exists the query synthesizes one from the playout's default schedule. Only valid for Classic playouts; other kinds return 422.",
|
||||
"operationId": "GetPlayoutAlternateSchedules",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Playouts"
|
||||
],
|
||||
"summary": "Replace a classic playout's alternate schedules",
|
||||
"description": "Replaces a Classic playout's alternate schedules. Items are ordered by priority: the first item is the highest priority and the last item is the catch-all default whose schedule becomes the playout's default schedule. Index is assigned from array order (the request body has no Index field). The list must contain at least one item, and every ProgramScheduleId must exist. Only valid for Classic playouts.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutAlternateSchedulesRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/playouts/{id}/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Playouts"
|
||||
],
|
||||
"summary": "Get a block playout's templates",
|
||||
"description": "Lists a Block playout's templates in priority order (first = highest priority). Each template optionally carries a deco template. Only valid for Block playouts; other kinds return 422.",
|
||||
"operationId": "GetPlayoutTemplates",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Playouts"
|
||||
],
|
||||
"summary": "Replace a block playout's templates",
|
||||
"description": "Replaces a Block playout's templates. Items are ordered by priority (first = highest priority); Index is assigned from array order (the request body has no Index field). Every TemplateId must exist, and any supplied DecoTemplateId must exist. An empty list clears all templates. Only valid for Block playouts.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReplacePlayoutTemplatesRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/playouts/reset-all": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -12983,6 +13395,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DayOfWeek": {
|
||||
"type": "integer"
|
||||
},
|
||||
"DecoBreakContentRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
@@ -14889,6 +15304,180 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayoutAlternateScheduleItemRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
"programScheduleId",
|
||||
"daysOfWeek",
|
||||
"daysOfMonth",
|
||||
"monthsOfYear",
|
||||
"limitToDateRange",
|
||||
"startMonth",
|
||||
"startDay",
|
||||
"startYear",
|
||||
"endMonth",
|
||||
"endDay",
|
||||
"endYear"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"programScheduleId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"daysOfWeek": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DayOfWeek"
|
||||
}
|
||||
},
|
||||
"daysOfMonth": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"monthsOfYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"limitToDateRange": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"endMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayoutAlternateScheduleResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"index",
|
||||
"programScheduleId",
|
||||
"daysOfWeek",
|
||||
"daysOfMonth",
|
||||
"monthsOfYear",
|
||||
"limitToDateRange",
|
||||
"startMonth",
|
||||
"startDay",
|
||||
"startYear",
|
||||
"endMonth",
|
||||
"endDay",
|
||||
"endYear"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"programScheduleId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"daysOfWeek": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DayOfWeek"
|
||||
}
|
||||
},
|
||||
"daysOfMonth": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"monthsOfYear": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"limitToDateRange": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"endMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayoutBuildMode": {
|
||||
"enum": [
|
||||
"Continue",
|
||||
@@ -15028,7 +15617,9 @@
|
||||
"scheduleName",
|
||||
"scheduleFile",
|
||||
"dailyRebuildTime",
|
||||
"buildStatus"
|
||||
"buildStatus",
|
||||
"decoId",
|
||||
"decoName"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15073,6 +15664,19 @@
|
||||
"$ref": "#/components/schemas/PlayoutBuildStatusResponseModel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"decoId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"decoName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15108,6 +15712,218 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayoutTemplateItemRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
"templateId",
|
||||
"decoTemplateId",
|
||||
"daysOfWeek",
|
||||
"daysOfMonth",
|
||||
"monthsOfYear",
|
||||
"limitToDateRange",
|
||||
"startMonth",
|
||||
"startDay",
|
||||
"startYear",
|
||||
"endMonth",
|
||||
"endDay",
|
||||
"endYear"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"templateId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"decoTemplateId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"daysOfWeek": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DayOfWeek"
|
||||
}
|
||||
},
|
||||
"daysOfMonth": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"monthsOfYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"limitToDateRange": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"endMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayoutTemplateResponseModel": {
|
||||
"required": [
|
||||
"id",
|
||||
"index",
|
||||
"templateId",
|
||||
"templateName",
|
||||
"templateGroupName",
|
||||
"decoTemplateId",
|
||||
"decoTemplateName",
|
||||
"decoTemplateGroupName",
|
||||
"daysOfWeek",
|
||||
"daysOfMonth",
|
||||
"monthsOfYear",
|
||||
"limitToDateRange",
|
||||
"startMonth",
|
||||
"startDay",
|
||||
"startYear",
|
||||
"endMonth",
|
||||
"endDay",
|
||||
"endYear"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"templateId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"templateName": {
|
||||
"type": "string"
|
||||
},
|
||||
"templateGroupName": {
|
||||
"type": "string"
|
||||
},
|
||||
"decoTemplateId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"decoTemplateName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"decoTemplateGroupName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"daysOfWeek": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DayOfWeek"
|
||||
}
|
||||
},
|
||||
"daysOfMonth": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"monthsOfYear": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"limitToDateRange": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"startYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"endMonth": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endDay": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"endYear": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProblemDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15589,6 +16405,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReplacePlayoutAlternateSchedulesRequest": {
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutAlternateScheduleItemRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReplacePlayoutTemplatesRequest": {
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayoutTemplateItemRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReplaceScheduleItemsRequest": {
|
||||
"required": [
|
||||
"items"
|
||||
|
||||
@@ -1674,6 +1674,108 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(fetchCount('/api/playouts')).toBe(2);
|
||||
});
|
||||
|
||||
// Regression coverage for the live-E2E finding: clicking "Alternate schedules" / "Templates"
|
||||
// used to update the URL via navigateToPath's pushState + synthetic popstate but never swap
|
||||
// the rendered screen, because routeFromLocation() returns the SAME ScreenRoute object
|
||||
// reference for /app/playouts and every /app/playouts/{id}/* sub-path, so React's
|
||||
// setActiveRoute(sameRef) bailed via Object.is and ScreenContent never re-ran. The fix (see
|
||||
// PlayoutsRouteScreen in App.tsx) mirrors BlocksScreen/TemplatesScreen/DecosScreen: it owns its
|
||||
// own pathname state and popstate listener instead of relying on the shell's activeRoute.
|
||||
it('client-side navigates into and back out of the alternate-schedules editor without a reload', async () => {
|
||||
mockDashboardApi({
|
||||
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' })], totalCount: 1 },
|
||||
schedules: [{ id: 7, name: 'Alpha' }],
|
||||
playoutAlternateSchedules: [
|
||||
{
|
||||
id: 1,
|
||||
index: 0,
|
||||
programScheduleId: 7,
|
||||
daysOfWeek: [],
|
||||
daysOfMonth: [],
|
||||
monthsOfYear: [],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alternate schedules' }));
|
||||
|
||||
// Synchronous assertion (no findBy/await): the editor's initial render happens in the same
|
||||
// tick as the click, before its data fetch resolves - proving the screen actually swapped
|
||||
// instead of silently staying on the Playouts list while only the URL changed.
|
||||
expect(screen.getByText('Loading alternate schedules…')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/playouts/20/alternate-schedules');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/playouts');
|
||||
});
|
||||
|
||||
it('client-side navigates into and back out of the block-templates editor without a reload', async () => {
|
||||
mockDashboardApi({
|
||||
playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' }),
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: {
|
||||
page: [listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleKind: 'Block' })],
|
||||
totalCount: 1
|
||||
},
|
||||
templates: [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }],
|
||||
playoutTemplateItems: [
|
||||
{
|
||||
id: 1,
|
||||
index: 0,
|
||||
templateId: 3,
|
||||
templateName: 'Weekdays',
|
||||
templateGroupName: 'Grp',
|
||||
decoTemplateId: null,
|
||||
decoTemplateName: null,
|
||||
decoTemplateGroupName: null,
|
||||
daysOfWeek: [],
|
||||
daysOfMonth: [],
|
||||
monthsOfYear: [],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Templates' }));
|
||||
|
||||
expect(screen.getByText('Loading playout templates…')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Retro Cartoons' })).not.toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/playouts/20/templates');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'All playouts' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/playouts');
|
||||
});
|
||||
|
||||
it('shows the Playouts loading state', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = input.toString();
|
||||
@@ -3431,6 +3533,10 @@ function mockDashboardApi({
|
||||
channelTemplates = [],
|
||||
createTemplateResponse = null,
|
||||
defaultChannelTemplate = null,
|
||||
playoutAlternateSchedules = [],
|
||||
playoutTemplateItems = [],
|
||||
templates = [],
|
||||
decoTemplates = [],
|
||||
ffmpegProfiles = [],
|
||||
fromLineupFailure = null,
|
||||
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 },
|
||||
@@ -3492,6 +3598,10 @@ function mockDashboardApi({
|
||||
channelTemplates?: unknown[];
|
||||
createTemplateResponse?: unknown;
|
||||
defaultChannelTemplate?: unknown;
|
||||
playoutAlternateSchedules?: unknown[];
|
||||
playoutTemplateItems?: unknown[];
|
||||
templates?: unknown[];
|
||||
decoTemplates?: unknown[];
|
||||
ffmpegProfiles?: unknown[];
|
||||
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
|
||||
fromLineupResponse?: unknown;
|
||||
@@ -3772,6 +3882,22 @@ function mockDashboardApi({
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/playouts\/\d+\/alternate-schedules$/)) {
|
||||
return Promise.resolve(jsonResponse(playoutAlternateSchedules));
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/playouts\/\d+\/templates$/)) {
|
||||
return Promise.resolve(jsonResponse(playoutTemplateItems));
|
||||
}
|
||||
|
||||
if (path === '/api/templates') {
|
||||
return Promise.resolve(jsonResponse(templates));
|
||||
}
|
||||
|
||||
if (path === '/api/deco-templates') {
|
||||
return Promise.resolve(jsonResponse(decoTemplates));
|
||||
}
|
||||
|
||||
const itemsMatch = path.match(/^\/api\/playouts\/\d+\/items(\?(?<query>.*))?$/);
|
||||
|
||||
if (itemsMatch) {
|
||||
|
||||
+131
-3
@@ -78,7 +78,11 @@ import { BlocksScreen } from './screens/BlocksScreen';
|
||||
import { DecosScreen } from './screens/DecosScreen';
|
||||
import { DecoTemplatesScreen } from './screens/DecoTemplatesScreen';
|
||||
import { TemplatesScreen } from './screens/TemplatesScreen';
|
||||
import { navigateToPath } from './routing';
|
||||
import {
|
||||
PlayoutAlternateSchedulesScreen,
|
||||
PlayoutTemplatesEditorScreen
|
||||
} from './screens/PlayoutScheduleEditors';
|
||||
import { navigateToPath, parsePlayoutSubRoute } from './routing';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -106,12 +110,14 @@ import {
|
||||
bulkRenumberChannels,
|
||||
createPlayout,
|
||||
deleteChannel,
|
||||
getDecos,
|
||||
messageFromError,
|
||||
addScheduleItem,
|
||||
deleteScheduleItem,
|
||||
getSchedules,
|
||||
resetAllPlayouts,
|
||||
replaceScheduleItems,
|
||||
updatePlayoutDefaultDeco,
|
||||
updatePlayoutDetails,
|
||||
usePlayoutsScreenQuery,
|
||||
useScheduleScreenQuery,
|
||||
@@ -128,6 +134,7 @@ import {
|
||||
type ChannelGuideProgramme,
|
||||
type CreatePlayoutRequest,
|
||||
type DashboardChannel,
|
||||
type DecoListItem,
|
||||
type SchedulePickerData,
|
||||
type DashboardChannelState,
|
||||
type DashboardHealthQueryState,
|
||||
@@ -315,6 +322,8 @@ const routes: ScreenRoute[] = [
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The alternate-schedules / templates editors live at sub-paths
|
||||
// (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates).
|
||||
id: 'playouts',
|
||||
path: '/app/playouts',
|
||||
label: 'Playouts',
|
||||
@@ -324,7 +333,8 @@ const routes: ScreenRoute[] = [
|
||||
icon: <ListVideo aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Reset All',
|
||||
placeholder: 'Playouts workspace',
|
||||
badge: 3
|
||||
badge: 3,
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
@@ -3143,6 +3153,31 @@ function PlayoutsScreen() {
|
||||
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
|
||||
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
|
||||
</div>
|
||||
{selectedSummary.scheduleKind === 'Classic' && (
|
||||
<div className="ctv-playout-detail-actions">
|
||||
<Button
|
||||
onClick={() => navigateToPath(`/app/playouts/${selectedSummary.id}/alternate-schedules`)}
|
||||
size="sm"
|
||||
startIcon={<CalendarClock aria-hidden="true" size={13} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Alternate schedules
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selectedSummary.scheduleKind === 'Block' && playout && (
|
||||
<div className="ctv-playout-detail-actions">
|
||||
<Button
|
||||
onClick={() => navigateToPath(`/app/playouts/${selectedSummary.id}/templates`)}
|
||||
size="sm"
|
||||
startIcon={<CalendarDays aria-hidden="true" size={13} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Templates
|
||||
</Button>
|
||||
<PlayoutDefaultDecoField playout={playout} onSaved={query.refresh} />
|
||||
</div>
|
||||
)}
|
||||
<div className="ctv-playout-detail-actions" title="No per-playout reset endpoint exists yet">
|
||||
<Button disabled size="sm" startIcon={<RefreshCw aria-hidden="true" size={13} />} variant="secondary">Reset</Button>
|
||||
<Button disabled size="sm" startIcon={<Clock aria-hidden="true" size={13} />} variant="ghost">Schedule reset</Button>
|
||||
@@ -3215,6 +3250,70 @@ function PlayoutsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) {
|
||||
const [decos, setDecos] = useState<DecoListItem[] | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
getDecos()
|
||||
.then((list) => {
|
||||
if (activeRef.current) {
|
||||
setDecos(list.filter((deco) => deco.id > 0));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (activeRef.current) {
|
||||
setDecos([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const options = [
|
||||
{ label: 'None (no default deco)', value: '' },
|
||||
...(decos ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => `${a.decoGroupName}${a.name}`.localeCompare(`${b.decoGroupName}${b.name}`))
|
||||
.map((deco) => ({ label: `${deco.decoGroupName} / ${deco.name}`, value: String(deco.id) }))
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<Select
|
||||
disabled={decos === null || saving}
|
||||
label="Default deco"
|
||||
onChange={(event) => {
|
||||
const nextId = event.target.value ? Number(event.target.value) : null;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
updatePlayoutDefaultDeco(playout.id, nextId)
|
||||
.then(() => {
|
||||
onSaved();
|
||||
})
|
||||
.catch((saveError: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setError(messageFromError(saveError));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRef.current) {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
options={options}
|
||||
value={playout.decoId == null ? '' : String(playout.decoId)}
|
||||
/>
|
||||
{error && <span className="ctv-settings-savebar-error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutTimeline({ items, itemsLoading, nowItem }: { items: PlayoutItem[]; itemsLoading: boolean; nowItem: PlayoutItem | null }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -3410,6 +3509,35 @@ function NotFoundScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// The Playouts nav entry owns two sub-path editors (/app/playouts/{id}/alternate-schedules and
|
||||
// /app/playouts/{id}/templates). Like BlocksScreen/TemplatesScreen/DecosScreen, this wrapper owns
|
||||
// its own pathname state and popstate listener so client-side navigation (pushState + synthetic
|
||||
// popstate via navigateToPath) re-renders the correct sub-screen: routeFromLocation() returns the
|
||||
// SAME ScreenRoute object reference for the base path and every sub-path of an allowSubPaths route,
|
||||
// so App's setActiveRoute(routeFromLocation()) bails via Object.is and never re-invokes
|
||||
// ScreenContent. Tracking pathname locally here sidesteps that entirely.
|
||||
function PlayoutsRouteScreen() {
|
||||
const [pathname, setPathname] = useState(() => window.location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname);
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
const sub = parsePlayoutSubRoute(pathname);
|
||||
|
||||
if (sub?.kind === 'alternate-schedules') {
|
||||
return <PlayoutAlternateSchedulesScreen key={pathname} playoutId={sub.id} />;
|
||||
}
|
||||
|
||||
if (sub?.kind === 'templates') {
|
||||
return <PlayoutTemplatesEditorScreen key={pathname} playoutId={sub.id} />;
|
||||
}
|
||||
|
||||
return <PlayoutsScreen />;
|
||||
}
|
||||
|
||||
function ScreenContent({
|
||||
healthState,
|
||||
route
|
||||
@@ -3462,7 +3590,7 @@ function ScreenContent({
|
||||
}
|
||||
|
||||
if (route.id === 'playouts') {
|
||||
return <PlayoutsScreen />;
|
||||
return <PlayoutsRouteScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'libraries') {
|
||||
|
||||
Vendored
+73
@@ -462,6 +462,7 @@ export interface components {
|
||||
"zIndex": number;
|
||||
"placeWithinSourceContent": boolean;
|
||||
};
|
||||
"DayOfWeek": number;
|
||||
"DecoBreakContentRequest": {
|
||||
"id": number;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
@@ -817,6 +818,35 @@ export interface components {
|
||||
"playlistGroupId": number;
|
||||
"name": null | string;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlayoutAlternateScheduleItemRequest": {
|
||||
"id": number;
|
||||
"programScheduleId": number;
|
||||
"daysOfWeek": null | Array<components["schemas"]["DayOfWeek"]>;
|
||||
"daysOfMonth": null | Array<number>;
|
||||
"monthsOfYear": null | Array<number>;
|
||||
"limitToDateRange": boolean;
|
||||
"startMonth": number;
|
||||
"startDay": number;
|
||||
"startYear": null | number;
|
||||
"endMonth": number;
|
||||
"endDay": number;
|
||||
"endYear": null | number;
|
||||
};
|
||||
"PlayoutAlternateScheduleResponseModel": {
|
||||
"id": number;
|
||||
"index": number;
|
||||
"programScheduleId": number;
|
||||
"daysOfWeek": Array<components["schemas"]["DayOfWeek"]>;
|
||||
"daysOfMonth": Array<number>;
|
||||
"monthsOfYear": Array<number>;
|
||||
"limitToDateRange": boolean;
|
||||
"startMonth": number;
|
||||
"startDay": number;
|
||||
"startYear": null | number;
|
||||
"endMonth": number;
|
||||
"endDay": number;
|
||||
"endYear": null | number;
|
||||
};
|
||||
"PlayoutBuildMode": "Continue" | "Refresh" | "Reset";
|
||||
"PlayoutBuildStatusResponseModel": {
|
||||
@@ -851,12 +881,49 @@ export interface components {
|
||||
"scheduleFile": null | string;
|
||||
"dailyRebuildTime": null | string;
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
"decoId": null | number;
|
||||
"decoName": null | string;
|
||||
};
|
||||
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
|
||||
"PlayoutSettingsResponseModel": {
|
||||
"daysToBuild": number;
|
||||
"skipMissingItems": boolean;
|
||||
"scriptedScheduleTimeoutSeconds": number;
|
||||
};
|
||||
"PlayoutTemplateItemRequest": {
|
||||
"id": number;
|
||||
"templateId": number;
|
||||
"decoTemplateId": null | number;
|
||||
"daysOfWeek": null | Array<components["schemas"]["DayOfWeek"]>;
|
||||
"daysOfMonth": null | Array<number>;
|
||||
"monthsOfYear": null | Array<number>;
|
||||
"limitToDateRange": boolean;
|
||||
"startMonth": number;
|
||||
"startDay": number;
|
||||
"startYear": null | number;
|
||||
"endMonth": number;
|
||||
"endDay": number;
|
||||
"endYear": null | number;
|
||||
};
|
||||
"PlayoutTemplateResponseModel": {
|
||||
"id": number;
|
||||
"index": number;
|
||||
"templateId": number;
|
||||
"templateName": string;
|
||||
"templateGroupName": string;
|
||||
"decoTemplateId": null | number;
|
||||
"decoTemplateName": null | string;
|
||||
"decoTemplateGroupName": null | string;
|
||||
"daysOfWeek": Array<components["schemas"]["DayOfWeek"]>;
|
||||
"daysOfMonth": Array<number>;
|
||||
"monthsOfYear": Array<number>;
|
||||
"limitToDateRange": boolean;
|
||||
"startMonth": number;
|
||||
"startDay": number;
|
||||
"startYear": null | number;
|
||||
"endMonth": number;
|
||||
"endDay": number;
|
||||
"endYear": null | number;
|
||||
};
|
||||
"ProblemDetails": {
|
||||
"type"?: null | string;
|
||||
@@ -949,6 +1016,12 @@ export interface components {
|
||||
"ReplaceDecoTemplateRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["DecoTemplateItemRequest"]>;
|
||||
};
|
||||
"ReplacePlayoutAlternateSchedulesRequest": {
|
||||
"items": null | Array<components["schemas"]["PlayoutAlternateScheduleItemRequest"]>;
|
||||
};
|
||||
"ReplacePlayoutTemplatesRequest": {
|
||||
"items": null | Array<components["schemas"]["PlayoutTemplateItemRequest"]>;
|
||||
};
|
||||
"ReplaceScheduleItemsRequest": {
|
||||
"items": null | Array<components["schemas"]["ScheduleItemRequest"]>;
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlayout, updatePlayoutDetails, type CreatePlayoutRequest } from './playouts';
|
||||
import {
|
||||
createPlayout,
|
||||
getAlternateSchedules,
|
||||
getPlayoutTemplates,
|
||||
replaceAlternateSchedules,
|
||||
replacePlayoutTemplates,
|
||||
updatePlayoutDefaultDeco,
|
||||
updatePlayoutDetails,
|
||||
type CreatePlayoutRequest
|
||||
} from './playouts';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -75,4 +84,95 @@ describe('playouts api client', () => {
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: null });
|
||||
});
|
||||
|
||||
it('updatePlayoutDefaultDeco PUTs the deco id to the deco route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
|
||||
|
||||
await updatePlayoutDefaultDeco(3, 7);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/3/deco');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 });
|
||||
});
|
||||
|
||||
it('getAlternateSchedules GETs the alternate-schedules route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await getAlternateSchedules(5);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/5/alternate-schedules');
|
||||
expect(init?.method ?? 'GET').toBe('GET');
|
||||
});
|
||||
|
||||
it('replaceAlternateSchedules PUTs items with day-name strings', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await replaceAlternateSchedules(5, {
|
||||
items: [
|
||||
{
|
||||
id: 0,
|
||||
programScheduleId: 2,
|
||||
daysOfWeek: ['Monday', 'Tuesday'],
|
||||
daysOfMonth: [1, 2],
|
||||
monthsOfYear: [1],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/5/alternate-schedules');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(body.items[0].daysOfWeek).toEqual(['Monday', 'Tuesday']);
|
||||
expect(body.items[0].programScheduleId).toBe(2);
|
||||
});
|
||||
|
||||
it('getPlayoutTemplates GETs the templates route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await getPlayoutTemplates(5);
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/5/templates');
|
||||
});
|
||||
|
||||
it('replacePlayoutTemplates PUTs template items', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await replacePlayoutTemplates(5, {
|
||||
items: [
|
||||
{
|
||||
id: 0,
|
||||
templateId: 3,
|
||||
decoTemplateId: null,
|
||||
daysOfWeek: ['Sunday'],
|
||||
daysOfMonth: [1],
|
||||
monthsOfYear: [1],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/5/templates');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
const body = JSON.parse(String(init?.body));
|
||||
expect(body.items[0].templateId).toBe(3);
|
||||
expect(body.items[0].decoTemplateId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,49 @@ export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
|
||||
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
|
||||
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
|
||||
|
||||
// The OpenAPI generator types DayOfWeek as `number` (the OpenAPI schema is derived from
|
||||
// System.Text.Json metadata), but the MVC pipeline serializes with Newtonsoft + StringEnumConverter,
|
||||
// so DayOfWeek is a day-name string on the wire ("Sunday" … "Saturday"). We override the generated
|
||||
// `daysOfWeek` shape accordingly on both request and response DTOs.
|
||||
export type DayOfWeek =
|
||||
| 'Sunday'
|
||||
| 'Monday'
|
||||
| 'Tuesday'
|
||||
| 'Wednesday'
|
||||
| 'Thursday'
|
||||
| 'Friday'
|
||||
| 'Saturday';
|
||||
|
||||
// Monday-first, matching the Blazor editor's ordering.
|
||||
export const DAYS_OF_WEEK: DayOfWeek[] = [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday'
|
||||
];
|
||||
|
||||
type WithDayNames<T> = Omit<T, 'daysOfWeek'> & { daysOfWeek: DayOfWeek[] };
|
||||
|
||||
export type PlayoutAlternateSchedule = WithDayNames<components['schemas']['PlayoutAlternateScheduleResponseModel']>;
|
||||
export type PlayoutAlternateScheduleItemRequest = WithDayNames<
|
||||
components['schemas']['PlayoutAlternateScheduleItemRequest']
|
||||
>;
|
||||
export type PlayoutTemplate = WithDayNames<components['schemas']['PlayoutTemplateResponseModel']>;
|
||||
export type PlayoutTemplateItemRequest = WithDayNames<components['schemas']['PlayoutTemplateItemRequest']>;
|
||||
|
||||
// Declared as `type` (not `interface`) so they satisfy the client's `Record<string, unknown>`
|
||||
// RequestBody bound, matching the generated request DTOs.
|
||||
export type ReplacePlayoutAlternateSchedulesRequest = {
|
||||
items: PlayoutAlternateScheduleItemRequest[];
|
||||
};
|
||||
|
||||
export type ReplacePlayoutTemplatesRequest = {
|
||||
items: PlayoutTemplateItemRequest[];
|
||||
};
|
||||
|
||||
export interface PlayoutsScreenData {
|
||||
channelStates: PlayoutChannelState[];
|
||||
items: PlayoutItem[];
|
||||
@@ -81,6 +124,45 @@ export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetai
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlayoutDefaultDeco(playoutId: number, decoId: number | null): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>(`/api/playouts/${playoutId}/deco`, {
|
||||
body: { decoId },
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function getAlternateSchedules(playoutId: number): Promise<PlayoutAlternateSchedule[]> {
|
||||
return request<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`);
|
||||
}
|
||||
|
||||
export function replaceAlternateSchedules(
|
||||
playoutId: number,
|
||||
body: ReplacePlayoutAlternateSchedulesRequest
|
||||
): Promise<PlayoutAlternateSchedule[]> {
|
||||
return request<PlayoutAlternateSchedule[]>(`/api/playouts/${playoutId}/alternate-schedules`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlayoutTemplates(playoutId: number): Promise<PlayoutTemplate[]> {
|
||||
return request<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`);
|
||||
}
|
||||
|
||||
export function replacePlayoutTemplates(
|
||||
playoutId: number,
|
||||
body: ReplacePlayoutTemplatesRequest
|
||||
): Promise<PlayoutTemplate[]> {
|
||||
return request<PlayoutTemplate[]>(`/api/playouts/${playoutId}/templates`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function messageFromPlayoutClientError(error: unknown, fallback = 'Unable to load playout'): string {
|
||||
return messageFromPlayoutError(error, fallback);
|
||||
}
|
||||
|
||||
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
||||
const [state, setState] = useState<PlayoutsScreenState>({
|
||||
data: null,
|
||||
|
||||
@@ -5,3 +5,31 @@ export function navigateToPath(path: string) {
|
||||
window.history.pushState(null, '', path);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// The Playouts screen owns two sub-path editors: /app/playouts/{id}/alternate-schedules (classic)
|
||||
// and /app/playouts/{id}/templates (block). Parsing lives here (not in the screen module) so the
|
||||
// screen file only exports components (react-refresh) while App.tsx's render switch can dispatch.
|
||||
export type PlayoutSubRoute = { id: number; kind: 'alternate-schedules' | 'templates' };
|
||||
|
||||
export function parsePlayoutSubRoute(pathname: string): PlayoutSubRoute | null {
|
||||
const base = '/app/playouts';
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
if (!normalized.startsWith(`${base}/`)) {
|
||||
return null;
|
||||
}
|
||||
const parts = normalized.slice(base.length + 1).split('/');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
const id = Number(parts[0]);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (parts[1] === 'alternate-schedules') {
|
||||
return { id, kind: 'alternate-schedules' };
|
||||
}
|
||||
if (parts[1] === 'templates') {
|
||||
return { id, kind: 'templates' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlayoutAlternateSchedulesScreen, PlayoutTemplatesEditorScreen } from './PlayoutScheduleEditors';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const ALL_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const ALL_DOM = Array.from({ length: 31 }, (_, index) => index + 1);
|
||||
const ALL_MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
|
||||
function recurrence(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
daysOfWeek: [...ALL_DAYS],
|
||||
daysOfMonth: [...ALL_DOM],
|
||||
monthsOfYear: [...ALL_MONTHS],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const classicPlayout = { id: 5, channelName: 'Kids', scheduleKind: 'Classic', decoId: null, decoName: null };
|
||||
const blockPlayout = { id: 5, channelName: 'Kids', scheduleKind: 'Block', decoId: null, decoName: null };
|
||||
|
||||
const schedules = [
|
||||
{ id: 7, name: 'Alpha' },
|
||||
{ id: 8, name: 'Beta' }
|
||||
];
|
||||
|
||||
const alternates = [
|
||||
{ id: 1, index: 0, programScheduleId: 7, ...recurrence({ daysOfWeek: ['Monday'] }) },
|
||||
{ id: 2, index: 1, programScheduleId: 8, ...recurrence() }
|
||||
];
|
||||
|
||||
function mockAltApi(onRequest?: (url: string, method: string, body: unknown) => Response | null) {
|
||||
return 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;
|
||||
|
||||
const override = onRequest?.(url, method, body);
|
||||
if (override) {
|
||||
return Promise.resolve(override);
|
||||
}
|
||||
|
||||
if (url === '/api/playouts/5' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(classicPlayout));
|
||||
}
|
||||
if (url === '/api/playouts/5/alternate-schedules' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(alternates));
|
||||
}
|
||||
if (url === '/api/schedules' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(schedules));
|
||||
}
|
||||
if (url === '/api/playouts/5/alternate-schedules' && method === 'PUT') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
const templates = [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }];
|
||||
const playoutTemplates = [
|
||||
{
|
||||
id: 1,
|
||||
index: 0,
|
||||
templateId: 3,
|
||||
templateName: 'Weekdays',
|
||||
templateGroupName: 'Grp',
|
||||
decoTemplateId: null,
|
||||
decoTemplateName: null,
|
||||
decoTemplateGroupName: null,
|
||||
...recurrence()
|
||||
}
|
||||
];
|
||||
|
||||
function mockTemplatesApi() {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/playouts/5' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(blockPlayout));
|
||||
}
|
||||
if (url === '/api/playouts/5/templates' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(playoutTemplates));
|
||||
}
|
||||
if (url === '/api/templates' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(templates));
|
||||
}
|
||||
if (url === '/api/deco-templates' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
if (url === '/api/playouts/5/templates' && method === 'PUT') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('PlayoutAlternateSchedulesScreen', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads the alternate schedules and marks the last row as the default', async () => {
|
||||
mockAltApi();
|
||||
render(<PlayoutAlternateSchedulesScreen playoutId={5} />);
|
||||
|
||||
expect(await screen.findByText('Alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves items in display order (index derived from array position)', async () => {
|
||||
const fetchMock = mockAltApi();
|
||||
render(<PlayoutAlternateSchedulesScreen playoutId={5} />);
|
||||
|
||||
await screen.findByText('Alpha');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/playouts/5/alternate-schedules' && init?.method === 'PUT'
|
||||
);
|
||||
expect(call).toBeTruthy();
|
||||
});
|
||||
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/playouts/5/alternate-schedules' && init?.method === 'PUT'
|
||||
);
|
||||
const sent = JSON.parse(String(putCall?.[1]?.body));
|
||||
expect(sent.items.map((item: { programScheduleId: number }) => item.programScheduleId)).toEqual([7, 8]);
|
||||
expect(sent.items[0].daysOfWeek).toEqual(['Monday']);
|
||||
});
|
||||
|
||||
it('gates the start/end date pickers behind the limit-to-date-range checkbox', async () => {
|
||||
mockAltApi();
|
||||
render(<PlayoutAlternateSchedulesScreen playoutId={5} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Alpha'));
|
||||
|
||||
expect(await screen.findByText('Edit schedule')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Start day')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(await screen.findByText('Start day')).toBeInTheDocument();
|
||||
expect(screen.getByText('End day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables recurrence controls and shows a note when the default (last) row is selected', async () => {
|
||||
mockAltApi();
|
||||
render(<PlayoutAlternateSchedulesScreen playoutId={5} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Beta'));
|
||||
|
||||
expect(await screen.findByText('Edit schedule')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The default entry always matches every day; reorder it above another entry to scope it.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('leaves recurrence controls enabled for a non-default row', async () => {
|
||||
mockAltApi();
|
||||
render(<PlayoutAlternateSchedulesScreen playoutId={5} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Alpha'));
|
||||
|
||||
expect(await screen.findByText('Edit schedule')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('The default entry always matches every day; reorder it above another entry to scope it.')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayoutTemplatesEditorScreen', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads playout templates and enables save when every row has a template', async () => {
|
||||
mockTemplatesApi();
|
||||
render(<PlayoutTemplatesEditorScreen playoutId={5} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(0));
|
||||
expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables save while a row is missing a template', async () => {
|
||||
mockTemplatesApi();
|
||||
render(<PlayoutTemplatesEditorScreen playoutId={5} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(0));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
expect(screen.getByText('Every row needs a template selected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,925 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowLeft, ArrowUp, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, Checkbox, IconButton, Select, Spinner } from '../components';
|
||||
import {
|
||||
DAYS_OF_WEEK,
|
||||
getAlternateSchedules,
|
||||
getDecoTemplates,
|
||||
getPlayout,
|
||||
getPlayoutTemplates,
|
||||
getSchedules,
|
||||
getTemplates,
|
||||
messageFromPlayoutClientError,
|
||||
replaceAlternateSchedules,
|
||||
replacePlayoutTemplates,
|
||||
type DayOfWeek,
|
||||
type DecoTemplate,
|
||||
type PlayoutAlternateSchedule,
|
||||
type PlayoutTemplate,
|
||||
type ProgramSchedule,
|
||||
type Template
|
||||
} from '../api';
|
||||
|
||||
const PLAYOUTS_PATH = '/app/playouts';
|
||||
|
||||
// The Blazor editors offer a 50-year forward range; a shorter window keeps the picker usable in the
|
||||
// SPA (deviation stated in the S6 handoff). "Any" (null) leaves the year unconstrained.
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const YEAR_OPTIONS = [
|
||||
{ label: 'Any', value: '' },
|
||||
...Array.from({ length: 12 }, (_, index) => {
|
||||
const year = CURRENT_YEAR + index;
|
||||
return { label: String(year), value: String(year) };
|
||||
})
|
||||
];
|
||||
|
||||
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, index) => index + 1);
|
||||
const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
const WEEKDAYS: DayOfWeek[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||
const WEEKENDS: DayOfWeek[] = ['Saturday', 'Sunday'];
|
||||
|
||||
function monthName(month: number, style: 'long' | 'short'): string {
|
||||
return new Date(2000, month - 1, 1).toLocaleString(undefined, { month: style });
|
||||
}
|
||||
|
||||
function shortDayName(day: DayOfWeek): string {
|
||||
return day.slice(0, 3);
|
||||
}
|
||||
|
||||
const MONTH_OPTIONS = MONTHS.map((month) => ({ label: monthName(month, 'long'), value: String(month) }));
|
||||
const DAY_OF_MONTH_OPTIONS = DAYS_OF_MONTH.map((day) => ({ label: String(day), value: String(day) }));
|
||||
|
||||
interface RecurrenceDraft {
|
||||
daysOfWeek: DayOfWeek[];
|
||||
daysOfMonth: number[];
|
||||
monthsOfYear: number[];
|
||||
limitToDateRange: boolean;
|
||||
startMonth: number;
|
||||
startDay: number;
|
||||
startYear: number | null;
|
||||
endMonth: number;
|
||||
endDay: number;
|
||||
endYear: number | null;
|
||||
}
|
||||
|
||||
function newRecurrence(): RecurrenceDraft {
|
||||
return {
|
||||
daysOfWeek: [...DAYS_OF_WEEK],
|
||||
daysOfMonth: [...DAYS_OF_MONTH],
|
||||
monthsOfYear: [...MONTHS],
|
||||
limitToDateRange: false,
|
||||
startMonth: 1,
|
||||
startDay: 1,
|
||||
startYear: null,
|
||||
endMonth: 12,
|
||||
endDay: 31,
|
||||
endYear: null
|
||||
};
|
||||
}
|
||||
|
||||
function toRequestRecurrence(draft: RecurrenceDraft) {
|
||||
return {
|
||||
daysOfWeek: draft.daysOfWeek,
|
||||
daysOfMonth: draft.daysOfMonth,
|
||||
monthsOfYear: draft.monthsOfYear,
|
||||
limitToDateRange: draft.limitToDateRange,
|
||||
startMonth: draft.startMonth,
|
||||
startDay: draft.startDay,
|
||||
startYear: draft.startYear,
|
||||
endMonth: draft.endMonth,
|
||||
endDay: draft.endDay,
|
||||
endYear: draft.endYear
|
||||
};
|
||||
}
|
||||
|
||||
// Formatting helpers mirror the Blazor summary columns: a full or empty set reads as "any".
|
||||
function daysOfWeekSummary(days: DayOfWeek[]): string {
|
||||
if (days.length === 0 || days.length === 7) {
|
||||
return 'any';
|
||||
}
|
||||
const ordered = DAYS_OF_WEEK.filter((day) => days.includes(day));
|
||||
return ordered.map(shortDayName).join(', ');
|
||||
}
|
||||
|
||||
function rangeSummary(values: number[], fullCount: number): string {
|
||||
if (values.length === 0 || values.length === fullCount) {
|
||||
return 'any';
|
||||
}
|
||||
const sorted = [...new Set(values)].sort((a, b) => a - b);
|
||||
const parts: string[] = [];
|
||||
let start = sorted[0];
|
||||
let prev = sorted[0];
|
||||
for (let i = 1; i <= sorted.length; i += 1) {
|
||||
if (i < sorted.length && sorted[i] === prev + 1) {
|
||||
prev = sorted[i];
|
||||
continue;
|
||||
}
|
||||
parts.push(start === prev ? String(start) : `${start}-${prev}`);
|
||||
if (i < sorted.length) {
|
||||
start = sorted[i];
|
||||
prev = sorted[i];
|
||||
}
|
||||
}
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function monthsSummary(months: number[]): string {
|
||||
if (months.length === 0 || months.length === 12) {
|
||||
return 'any';
|
||||
}
|
||||
return [...months]
|
||||
.sort((a, b) => a - b)
|
||||
.map((month) => monthName(month, 'short'))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function toggle<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value];
|
||||
}
|
||||
|
||||
let keySeq = 0;
|
||||
function nextKey(): string {
|
||||
keySeq += 1;
|
||||
return `row-${keySeq}`;
|
||||
}
|
||||
|
||||
// ----- Shared UI -----
|
||||
|
||||
function EditorChrome({
|
||||
channelName,
|
||||
title,
|
||||
hint,
|
||||
saving,
|
||||
saveDisabled,
|
||||
saveError,
|
||||
onAdd,
|
||||
onSave,
|
||||
children
|
||||
}: {
|
||||
channelName: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
saving: boolean;
|
||||
saveDisabled: boolean;
|
||||
saveError: null | string;
|
||||
onAdd: () => void;
|
||||
onSave: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Button
|
||||
onClick={() => navigateToPath(PLAYOUTS_PATH)}
|
||||
size="sm"
|
||||
startIcon={<ArrowLeft aria-hidden="true" size={14} />}
|
||||
variant="ghost"
|
||||
>
|
||||
All playouts
|
||||
</Button>
|
||||
<span className="ctv-collections-detail-title">
|
||||
{channelName} / {title}
|
||||
</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
|
||||
<Button onClick={onAdd} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="secondary">
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saveDisabled || saving}
|
||||
loading={saving}
|
||||
onClick={onSave}
|
||||
size="sm"
|
||||
startIcon={<Check aria-hidden="true" size={14} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ctv-channels-error" role="note" style={{ background: 'transparent', border: 'none' }}>
|
||||
<span>{hint}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipRow({
|
||||
values,
|
||||
options,
|
||||
onToggle,
|
||||
formatValue,
|
||||
disabled = false
|
||||
}: {
|
||||
values: number[] | DayOfWeek[];
|
||||
options: Array<number | DayOfWeek>;
|
||||
onToggle: (value: never) => void;
|
||||
formatValue: (value: never) => string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{options.map((option) => {
|
||||
const active = (values as Array<number | DayOfWeek>).includes(option);
|
||||
return (
|
||||
<button
|
||||
className={`ctv-chip-toggle${active ? ' ctv-chip-toggle-active' : ''}`}
|
||||
disabled={disabled}
|
||||
key={String(option)}
|
||||
onClick={() => onToggle(option as never)}
|
||||
type="button"
|
||||
>
|
||||
{formatValue(option as never)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecurrenceFields({
|
||||
draft,
|
||||
onChange,
|
||||
disabled = false,
|
||||
disabledNote
|
||||
}: {
|
||||
draft: RecurrenceDraft;
|
||||
onChange: (patch: Partial<RecurrenceDraft>) => void;
|
||||
disabled?: boolean;
|
||||
disabledNote?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{disabled && disabledNote && (
|
||||
<div className="ctv-channels-error" role="note" style={{ background: 'transparent', border: 'none' }}>
|
||||
<span>{disabledNote}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Limit to date range</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control">
|
||||
<Checkbox
|
||||
checked={draft.limitToDateRange}
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange({ limitToDateRange: next })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.limitToDateRange && (
|
||||
<>
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Start day</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ startMonth: Number(event.target.value) })}
|
||||
options={MONTH_OPTIONS}
|
||||
value={String(draft.startMonth)}
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ startDay: Number(event.target.value) })}
|
||||
options={DAY_OF_MONTH_OPTIONS}
|
||||
value={String(draft.startDay)}
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ startYear: event.target.value ? Number(event.target.value) : null })}
|
||||
options={YEAR_OPTIONS}
|
||||
value={draft.startYear === null ? '' : String(draft.startYear)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">End day</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ endMonth: Number(event.target.value) })}
|
||||
options={MONTH_OPTIONS}
|
||||
value={String(draft.endMonth)}
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ endDay: Number(event.target.value) })}
|
||||
options={DAY_OF_MONTH_OPTIONS}
|
||||
value={String(draft.endDay)}
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ endYear: event.target.value ? Number(event.target.value) : null })}
|
||||
options={YEAR_OPTIONS}
|
||||
value={draft.endYear === null ? '' : String(draft.endYear)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Days of the week</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
|
||||
<ChipRow
|
||||
disabled={disabled}
|
||||
formatValue={(day) => shortDayName(day)}
|
||||
onToggle={(day) => onChange({ daysOfWeek: toggle(draft.daysOfWeek, day) })}
|
||||
options={DAYS_OF_WEEK}
|
||||
values={draft.daysOfWeek}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button disabled={disabled} onClick={() => onChange({ daysOfWeek: [...WEEKDAYS] })} size="sm" variant="ghost">
|
||||
Weekdays
|
||||
</Button>
|
||||
<Button disabled={disabled} onClick={() => onChange({ daysOfWeek: [...WEEKENDS] })} size="sm" variant="ghost">
|
||||
Weekends
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Days of the month</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control">
|
||||
<ChipRow
|
||||
disabled={disabled}
|
||||
formatValue={(day) => String(day)}
|
||||
onToggle={(day) => onChange({ daysOfMonth: toggle(draft.daysOfMonth, day) })}
|
||||
options={DAYS_OF_MONTH}
|
||||
values={draft.daysOfMonth}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Months</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control">
|
||||
<ChipRow
|
||||
disabled={disabled}
|
||||
formatValue={(month) => monthName(month, 'short')}
|
||||
onToggle={(month) => onChange({ monthsOfYear: toggle(draft.monthsOfYear, month) })}
|
||||
options={MONTHS}
|
||||
values={draft.monthsOfYear}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecurrenceCells({ draft }: { draft: RecurrenceDraft }) {
|
||||
return (
|
||||
<>
|
||||
<td>{daysOfWeekSummary(draft.daysOfWeek)}</td>
|
||||
<td>{rangeSummary(draft.daysOfMonth, 31)}</td>
|
||||
<td>{monthsSummary(draft.monthsOfYear)}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<Spinner size={18} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadErrorState({ error }: { error: string }) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{error}</span>
|
||||
<Button onClick={() => navigateToPath(PLAYOUTS_PATH)} size="sm" variant="secondary">
|
||||
Back to playouts
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Alternate schedules editor (classic playouts) -----
|
||||
|
||||
interface AltDraftItem extends RecurrenceDraft {
|
||||
key: string;
|
||||
id: number;
|
||||
programScheduleId: number;
|
||||
}
|
||||
|
||||
export function PlayoutAlternateSchedulesScreen({ playoutId }: { playoutId: number }) {
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [schedules, setSchedules] = useState<ProgramSchedule[]>([]);
|
||||
const [items, setItems] = useState<AltDraftItem[] | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState<null | string>(null);
|
||||
const [loadError, setLoadError] = useState<null | string>(null);
|
||||
const [saveError, setSaveError] = useState<null | string>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
Promise.all([getPlayout(playoutId), getAlternateSchedules(playoutId), getSchedules()])
|
||||
.then(([playout, alternates, allSchedules]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setChannelName(playout.channelName);
|
||||
setSchedules(allSchedules);
|
||||
const drafts = alternates.map((alternate) => altToDraft(alternate, allSchedules));
|
||||
setItems(drafts);
|
||||
setSelectedKey(drafts.length === 1 ? drafts[0].key : null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setLoadError(messageFromPlayoutClientError(error, 'Unable to load alternate schedules'));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [playoutId]);
|
||||
|
||||
if (loadError) {
|
||||
return <LoadErrorState error={loadError} />;
|
||||
}
|
||||
if (!items) {
|
||||
return <LoadingState label="Loading alternate schedules…" />;
|
||||
}
|
||||
|
||||
const scheduleOptions = [...schedules]
|
||||
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
||||
.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: String(schedule.id) }));
|
||||
const selected = items.find((item) => item.key === selectedKey) ?? null;
|
||||
// Mirrors the "Default" badge below: the last row is the catch-all default whose recurrence
|
||||
// fields the server ignores entirely (ReplacePlayoutAlternateScheduleItemsHandler excludes the
|
||||
// highest-index item from the saved rows and only keeps its ProgramScheduleId as the playout's
|
||||
// default schedule) - so edits to those fields would silently vanish on save.
|
||||
const selectedIsDefault = selected !== null && items[items.length - 1]?.key === selected.key;
|
||||
|
||||
const patchSelected = (patch: Partial<AltDraftItem>) => {
|
||||
setItems((current) =>
|
||||
current ? current.map((item) => (item.key === selectedKey ? { ...item, ...patch } : item)) : current
|
||||
);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const item: AltDraftItem = {
|
||||
key: nextKey(),
|
||||
id: 0,
|
||||
programScheduleId: schedules[0]?.id ?? 0,
|
||||
...newRecurrence()
|
||||
};
|
||||
setItems((current) => [...(current ?? []), item]);
|
||||
setSelectedKey(item.key);
|
||||
};
|
||||
|
||||
const move = (key: string, delta: number) => {
|
||||
setItems((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const index = current.findIndex((item) => item.key === key);
|
||||
const target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= current.length) {
|
||||
return current;
|
||||
}
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (key: string) => {
|
||||
setItems((current) => (current ? current.filter((item) => item.key !== key) : current));
|
||||
if (selectedKey === key) {
|
||||
setSelectedKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!items || items.length === 0 || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
replaceAlternateSchedules(playoutId, {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
programScheduleId: item.programScheduleId,
|
||||
...toRequestRecurrence(item)
|
||||
}))
|
||||
})
|
||||
.then(() => {
|
||||
navigateToPath(PLAYOUTS_PATH);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setSaveError(messageFromPlayoutClientError(error, 'Unable to save alternate schedules'));
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleNameOf = (id: number) => schedules.find((schedule) => schedule.id === id)?.name ?? '(none)';
|
||||
// note: '(none)' also covers a schedule whose name is null
|
||||
|
||||
return (
|
||||
<EditorChrome
|
||||
channelName={channelName}
|
||||
hint="In priority order from top to bottom. The last entry always matches every day and month as the catch-all default, and its schedule becomes the playout's default schedule."
|
||||
onAdd={addItem}
|
||||
onSave={save}
|
||||
saveDisabled={items.length === 0}
|
||||
saveError={saveError}
|
||||
saving={saving}
|
||||
title="Alternate schedules"
|
||||
>
|
||||
<Card padded={false} title="Schedules">
|
||||
{items.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No alternate schedules. Add one to get started.</div>
|
||||
) : (
|
||||
<table className="ctv-channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schedule</th>
|
||||
<th>Days of the week</th>
|
||||
<th>Days of the month</th>
|
||||
<th>Months</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const isDefault = index === items.length - 1;
|
||||
return (
|
||||
<tr
|
||||
className={item.key === selectedKey ? 'ctv-channel-row-selected' : undefined}
|
||||
key={item.key}
|
||||
onClick={() => setSelectedKey(item.key)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td>
|
||||
{scheduleNameOf(item.programScheduleId)}
|
||||
{isDefault && (
|
||||
<>
|
||||
{' '}
|
||||
<Badge tone="accent">Default</Badge>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<RecurrenceCells draft={item} />
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }} onClick={(event) => event.stopPropagation()}>
|
||||
<IconButton
|
||||
disabled={index === 0}
|
||||
onClick={() => move(item.key, -1)}
|
||||
size="sm"
|
||||
title="Move up"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => move(item.key, 1)}
|
||||
size="sm"
|
||||
title="Move down"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={items.length === 1}
|
||||
onClick={() => remove(item.key)}
|
||||
size="sm"
|
||||
title="Delete"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<Card padded={false} title="Edit schedule">
|
||||
<div className="ctv-settings-row" style={{ borderTop: 'none' }}>
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Schedule</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: '0 0 360px' }}>
|
||||
<Select
|
||||
onChange={(event) => patchSelected({ programScheduleId: Number(event.target.value) })}
|
||||
options={scheduleOptions}
|
||||
value={String(selected.programScheduleId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RecurrenceFields
|
||||
disabled={selectedIsDefault}
|
||||
disabledNote="The default entry always matches every day; reorder it above another entry to scope it."
|
||||
draft={selected}
|
||||
onChange={patchSelected}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</EditorChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function altToDraft(alternate: PlayoutAlternateSchedule, schedules: ProgramSchedule[]): AltDraftItem {
|
||||
return {
|
||||
key: nextKey(),
|
||||
id: alternate.id,
|
||||
programScheduleId: alternate.programScheduleId || schedules[0]?.id || 0,
|
||||
daysOfWeek: [...alternate.daysOfWeek],
|
||||
daysOfMonth: [...alternate.daysOfMonth],
|
||||
monthsOfYear: [...alternate.monthsOfYear],
|
||||
limitToDateRange: alternate.limitToDateRange,
|
||||
startMonth: alternate.startMonth,
|
||||
startDay: alternate.startDay,
|
||||
startYear: alternate.startYear,
|
||||
endMonth: alternate.endMonth,
|
||||
endDay: alternate.endDay,
|
||||
endYear: alternate.endYear
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Templates editor (block playouts) -----
|
||||
|
||||
interface TemplateDraftItem extends RecurrenceDraft {
|
||||
key: string;
|
||||
id: number;
|
||||
templateId: number;
|
||||
decoTemplateId: number | null;
|
||||
}
|
||||
|
||||
export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number }) {
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [decoTemplates, setDecoTemplates] = useState<DecoTemplate[]>([]);
|
||||
const [items, setItems] = useState<TemplateDraftItem[] | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState<null | string>(null);
|
||||
const [loadError, setLoadError] = useState<null | string>(null);
|
||||
const [saveError, setSaveError] = useState<null | string>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
Promise.all([getPlayout(playoutId), getPlayoutTemplates(playoutId), getTemplates(), getDecoTemplates()])
|
||||
.then(([playout, playoutTemplates, allTemplates, allDecoTemplates]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setChannelName(playout.channelName);
|
||||
setTemplates(allTemplates);
|
||||
setDecoTemplates(allDecoTemplates);
|
||||
const drafts = playoutTemplates.map(templateToDraft);
|
||||
setItems(drafts);
|
||||
setSelectedKey(drafts.length === 1 ? drafts[0].key : null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setLoadError(messageFromPlayoutClientError(error, 'Unable to load playout templates'));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [playoutId]);
|
||||
|
||||
if (loadError) {
|
||||
return <LoadErrorState error={loadError} />;
|
||||
}
|
||||
if (!items) {
|
||||
return <LoadingState label="Loading playout templates…" />;
|
||||
}
|
||||
|
||||
const templateOptions = [
|
||||
{ label: 'Select a template…', value: '' },
|
||||
...[...templates]
|
||||
.filter((template) => template.id > 0)
|
||||
.sort((a, b) => `${a.groupName ?? ''}${a.name ?? ''}`.localeCompare(`${b.groupName ?? ''}${b.name ?? ''}`))
|
||||
.map((template) => ({ label: `${template.groupName ?? ''} / ${template.name ?? ''}`, value: String(template.id) }))
|
||||
];
|
||||
const decoTemplateOptions = [
|
||||
{ label: 'None', value: '' },
|
||||
...[...decoTemplates]
|
||||
.filter((decoTemplate) => decoTemplate.id > 0)
|
||||
.sort((a, b) => `${a.groupName ?? ''}${a.name ?? ''}`.localeCompare(`${b.groupName ?? ''}${b.name ?? ''}`))
|
||||
.map((decoTemplate) => ({
|
||||
label: `${decoTemplate.groupName ?? ''} / ${decoTemplate.name ?? ''}`,
|
||||
value: String(decoTemplate.id)
|
||||
}))
|
||||
];
|
||||
const selected = items.find((item) => item.key === selectedKey) ?? null;
|
||||
const missingTemplate = items.some((item) => item.templateId <= 0);
|
||||
|
||||
const patchSelected = (patch: Partial<TemplateDraftItem>) => {
|
||||
setItems((current) =>
|
||||
current ? current.map((item) => (item.key === selectedKey ? { ...item, ...patch } : item)) : current
|
||||
);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const item: TemplateDraftItem = {
|
||||
key: nextKey(),
|
||||
id: 0,
|
||||
templateId: 0,
|
||||
decoTemplateId: null,
|
||||
...newRecurrence()
|
||||
};
|
||||
setItems((current) => [...(current ?? []), item]);
|
||||
setSelectedKey(item.key);
|
||||
};
|
||||
|
||||
const move = (key: string, delta: number) => {
|
||||
setItems((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const index = current.findIndex((item) => item.key === key);
|
||||
const target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= current.length) {
|
||||
return current;
|
||||
}
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (key: string) => {
|
||||
setItems((current) => (current ? current.filter((item) => item.key !== key) : current));
|
||||
if (selectedKey === key) {
|
||||
setSelectedKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!items || missingTemplate || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
replacePlayoutTemplates(playoutId, {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
templateId: item.templateId,
|
||||
decoTemplateId: item.decoTemplateId,
|
||||
...toRequestRecurrence(item)
|
||||
}))
|
||||
})
|
||||
.then(() => {
|
||||
navigateToPath(PLAYOUTS_PATH);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setSaveError(messageFromPlayoutClientError(error, 'Unable to save playout templates'));
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const templateNameOf = (id: number) => templates.find((template) => template.id === id)?.name ?? '(none)';
|
||||
|
||||
return (
|
||||
<EditorChrome
|
||||
channelName={channelName}
|
||||
hint="In priority order from top to bottom. Each template optionally applies a deco template."
|
||||
onAdd={addItem}
|
||||
onSave={save}
|
||||
saveDisabled={missingTemplate}
|
||||
saveError={saveError ?? (missingTemplate ? 'Every row needs a template selected' : null)}
|
||||
saving={saving}
|
||||
title="Templates"
|
||||
>
|
||||
<Card padded={false} title="Templates">
|
||||
{items.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No templates. Add one to get started.</div>
|
||||
) : (
|
||||
<table className="ctv-channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Days of the week</th>
|
||||
<th>Days of the month</th>
|
||||
<th>Months</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
className={item.key === selectedKey ? 'ctv-channel-row-selected' : undefined}
|
||||
key={item.key}
|
||||
onClick={() => setSelectedKey(item.key)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td>{templateNameOf(item.templateId)}</td>
|
||||
<RecurrenceCells draft={item} />
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }} onClick={(event) => event.stopPropagation()}>
|
||||
<IconButton
|
||||
disabled={index === 0}
|
||||
onClick={() => move(item.key, -1)}
|
||||
size="sm"
|
||||
title="Move up"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => move(item.key, 1)}
|
||||
size="sm"
|
||||
title="Move down"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => remove(item.key)} size="sm" title="Delete" variant="ghost">
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<Card padded={false} title="Edit template">
|
||||
<div className="ctv-settings-row" style={{ borderTop: 'none' }}>
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Template</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: '0 0 360px' }}>
|
||||
<Select
|
||||
onChange={(event) => patchSelected({ templateId: Number(event.target.value) })}
|
||||
options={templateOptions}
|
||||
value={selected.templateId > 0 ? String(selected.templateId) : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row">
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">Deco template</div>
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: '0 0 360px' }}>
|
||||
<Select
|
||||
onChange={(event) =>
|
||||
patchSelected({ decoTemplateId: event.target.value ? Number(event.target.value) : null })
|
||||
}
|
||||
options={decoTemplateOptions}
|
||||
value={selected.decoTemplateId === null ? '' : String(selected.decoTemplateId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RecurrenceFields draft={selected} onChange={patchSelected} />
|
||||
</Card>
|
||||
)}
|
||||
</EditorChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function templateToDraft(template: PlayoutTemplate): TemplateDraftItem {
|
||||
return {
|
||||
key: nextKey(),
|
||||
id: template.id,
|
||||
templateId: template.templateId,
|
||||
decoTemplateId: template.decoTemplateId ?? null,
|
||||
daysOfWeek: [...template.daysOfWeek],
|
||||
daysOfMonth: [...template.daysOfMonth],
|
||||
monthsOfYear: [...template.monthsOfYear],
|
||||
limitToDateRange: template.limitToDateRange,
|
||||
startMonth: template.startMonth,
|
||||
startDay: template.startDay,
|
||||
startYear: template.startYear,
|
||||
endMonth: template.endMonth,
|
||||
endDay: template.endDay,
|
||||
endYear: template.endYear
|
||||
};
|
||||
}
|
||||
@@ -3711,3 +3711,26 @@
|
||||
height: 16px;
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
/* Playout alternate-schedule / template editors: multi-select day/month chips */
|
||||
.ctv-chip-toggle {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--ctv-border);
|
||||
background: var(--ctv-surface);
|
||||
color: var(--ctv-text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.ctv-chip-toggle:hover {
|
||||
border-color: var(--ctv-accent);
|
||||
}
|
||||
|
||||
.ctv-chip-toggle-active {
|
||||
background: var(--ctv-accent-soft);
|
||||
border-color: var(--ctv-accent);
|
||||
color: var(--ctv-text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user