diff --git a/backend/extensions/Squidex.Extensions/Actions/RuleEventMigrator.cs b/backend/extensions/Squidex.Extensions/Actions/RuleEventMigrator.cs new file mode 100644 index 000000000..1aa9c6a3c --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/RuleEventMigrator.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Extensions.Actions.Script; +using Squidex.Flows; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Extensions.Actions; + +public sealed class RuleEventMigrator(TypeRegistry typeRegistry, IJsonSerializer serializer) : IEventMigrator +{ + private readonly string[] migratedEvents = + [ + typeRegistry.GetName(typeof(RuleCreated)), + typeRegistry.GetName(typeof(RuleUpdated)), + ]; + + public string? MigrateEvent(string type, string json) + { + if (!migratedEvents.Contains(type)) + { + return null; + } + + var parsed = serializer.Deserialize(json); + + var isChanged = false; + void Handle(JsonValue value) + { + if (value.Type == JsonValueType.Object) + { + var obj = value.AsObject; + foreach (var (key, nested) in obj) + { + if (key == "$type" && nested.Value is string flowStepType) + { + if (!typeRegistry.TryGetType(flowStepType, out _)) + { + isChanged = true; + obj["$type"] = typeRegistry.GetName(); + } + } + else + { + Handle(nested); + } + } + } + else if (value.Type == JsonValueType.Array) + { + foreach (var item in value.AsArray) + { + Handle(item); + } + } + } + + Handle(parsed); + if (!isChanged) + { + return null; + } + + return serializer.Serialize(parsed); + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs new file mode 100644 index 000000000..f1bcba38a --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/RuleEventPlugin.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Actions; + +public sealed class RuleEventPlugin : IPlugin +{ + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + services.AddTransientAs() + .As(); + } +} diff --git a/backend/src/Migrations/Migrations/OldRuleEventMigrator.cs b/backend/src/Migrations/Migrations/OldRuleEventMigrator.cs new file mode 100644 index 000000000..eec26c8e4 --- /dev/null +++ b/backend/src/Migrations/Migrations/OldRuleEventMigrator.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Migrations.OldEvents; +using Squidex.Domain.Apps.Core.Rules.Deprecated; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Reflection; + +namespace Migrations.Migrations; + +public sealed class OldRuleEventMigrator(TypeRegistry typeRegistry, IJsonSerializer serializer) : IEventMigrator +{ +#pragma warning disable CS0612 // Type or member is obsolete +#pragma warning disable CS0618 // Type or member is obsolete + private readonly string[] migratedEvents = + [ + typeRegistry.GetName(typeof(RuleCreated)), + typeRegistry.GetName(typeof(RuleUpdated)), + ]; + + public string? MigrateEvent(string type, string json) + { + if (!migratedEvents.Contains(type)) + { + return null; + } + + var parsed = serializer.Deserialize(json); + + var isChanged = false; + void Handle(JsonValue value) + { + if (value.Type == JsonValueType.Object) + { + var obj = value.AsObject; + foreach (var (key, nested) in obj) + { + if (key == "actionType" && nested.Value is string flowStepType) + { + if (!typeRegistry.TryGetType(flowStepType, out _)) + { + isChanged = true; + obj["actionType"] = "Webhook"; + } + } + else + { + Handle(nested); + } + } + } + else if (value.Type == JsonValueType.Array) + { + foreach (var item in value.AsArray) + { + Handle(item); + } + } + } + + Handle(parsed); + if (!isChanged) + { + return null; + } + + return serializer.Serialize(parsed); + } +#pragma warning restore CS0612 // Type or member is obsolete +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs index d4f1ab340..0438e6dae 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -100,6 +100,7 @@ public sealed class BackupReader : DisposableObjectBase, IBackupReader public async IAsyncEnumerable<(string Stream, Envelope Event)> ReadEventsAsync( IEventStreamNames eventStreamNames, IEventFormatter eventFormatter, + IEnumerable eventMigrators, [EnumeratorCancellation] CancellationToken ct = default) { Guard.NotNull(eventFormatter); @@ -118,6 +119,19 @@ public sealed class BackupReader : DisposableObjectBase, IBackupReader { var storedEvent = serializer.Deserialize(stream).ToStoredEvent(); + foreach (var migrator in eventMigrators) + { + var migrated = migrator.MigrateEvent(storedEvent.Data.Type, storedEvent.Data.Payload); + if (migrated != null) + { + storedEvent = storedEvent with + { + Data = storedEvent.Data with { Payload = migrated }, + }; + break; + } + } + var eventStream = storedEvent.StreamName; var eventEnvelope = eventFormatter.Parse(storedEvent); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs index ea1d04a5a..04f4d0cef 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs @@ -16,6 +16,11 @@ public interface IBackupHandler int Order => 0; + public string ProcessEvent(string type, string json) + { + return json; + } + public Task RestoreEventAsync(Envelope @event, RestoreContext context, CancellationToken ct) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs index 8153b0511..1e2ac5a6c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs @@ -28,5 +28,6 @@ public interface IBackupReader : IDisposable IAsyncEnumerable<(string Stream, Envelope Event)> ReadEventsAsync( IEventStreamNames eventStreamNames, IEventFormatter eventFormatter, + IEnumerable eventMigrators, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/IEventMigrator.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/IEventMigrator.cs new file mode 100644 index 000000000..8dedbf813 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/IEventMigrator.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Backup; + +public interface IEventMigrator +{ + string? MigrateEvent(string type, string json); +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs index 3b37c1a8e..c59b75ec1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs @@ -31,6 +31,7 @@ public sealed class RestoreJob( IEventFormatter eventFormatter, IEventStore eventStore, IEventStreamNames eventStreamNames, + IEnumerable eventMigrators, IUserResolver userResolver, ILogger log) : IJobRunner @@ -171,6 +172,10 @@ public sealed class RestoreJob( log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", context.Job.Id, state.Url); throw; } + finally + { + state.Reader?.Dispose(); + } } private async Task AssignContributorAsync(JobRunContext run, State state) @@ -275,11 +280,6 @@ public sealed class RestoreJob( activity?.SetTag("totalCommits", commits.Count); activity?.SetTag("totalEvents", commits.Sum(x => x.Events.Count)); - if (commits.Any(x => x.StreamName.Contains("46b2fb05-3438-4b99-8c1d-bac8925a33dd"))) - { - Debugger.Break(); - } - await eventStore.AppendUnsafeAsync(commits, ct); } @@ -293,7 +293,7 @@ public sealed class RestoreJob( private async IAsyncEnumerable<(string Stream, long Offset, Envelope Event)> HandleEventsAsync(JobRunContext run, State state, [EnumeratorCancellation] CancellationToken ct) { - var @events = state.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, ct); + var @events = state.Reader.ReadEventsAsync(eventStreamNames, eventFormatter, eventMigrators, ct); await foreach (var (stream, @event) in events.WithCancellation(ct)) { diff --git a/backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs b/backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs index 833290c7e..8cf52f0d2 100644 --- a/backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs +++ b/backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs @@ -10,8 +10,6 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Squidex.Infrastructure.Reflection; -#pragma warning disable MA0084 // Local variables should not hide other symbols - namespace Squidex.Infrastructure.Json.System; public sealed class PolymorphicConverter : JsonConverter where T : class @@ -40,15 +38,15 @@ public sealed class PolymorphicConverter : JsonConverter where T : class { if (typeRegistry.TryGetConfig(baseType, out var config) && config.TryGetName(typeInfo.Type, out var typeName)) { - var discriminatorName = config.DiscriminatorProperty ?? Constants.DefaultDiscriminatorProperty; - var discriminatorField = typeInfo.CreateJsonPropertyInfo(typeof(string), discriminatorName); + var typeDiscriminatorName = config.DiscriminatorProperty ?? Constants.DefaultDiscriminatorProperty; + var typeDiscriminatorField = typeInfo.CreateJsonPropertyInfo(typeof(string), typeDiscriminatorName); - discriminatorField.Get = x => + typeDiscriminatorField.Get = x => { return typeName; }; - typeInfo.Properties.Insert(0, discriminatorField); + typeInfo.Properties.Insert(0, typeDiscriminatorField); } baseType = baseType.BaseType; diff --git a/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs b/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs index 445da03b2..7b380f2f1 100644 --- a/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs +++ b/backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs @@ -23,7 +23,6 @@ public static class PluginLoaders return PluginLoader.CreateFromAssemblyFile(candidate.FullName, config => { config.PreferSharedTypes = true; - config.SharedAssemblies.AddRange(sharedAssemblies); }); } diff --git a/backend/src/Squidex.Infrastructure/Reflection/DelegateTypeProvider.cs b/backend/src/Squidex.Infrastructure/Reflection/DelegateTypeProvider.cs new file mode 100644 index 000000000..422e830d7 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Reflection/DelegateTypeProvider.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Reflection; + +public sealed class DelegateTypeProvider(Action action) : ITypeProvider +{ + public void Map(TypeRegistry typeRegistry) + { + action(typeRegistry); + } +} diff --git a/backend/src/Squidex/Config/Domain/BackupsServices.cs b/backend/src/Squidex/Config/Domain/BackupsServices.cs index 2c1d1f582..c1c39448b 100644 --- a/backend/src/Squidex/Config/Domain/BackupsServices.cs +++ b/backend/src/Squidex/Config/Domain/BackupsServices.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Migrations.Migrations; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Flows; +using Squidex.Infrastructure.Reflection; namespace Squidex.Config.Domain; @@ -49,5 +52,8 @@ public static class BackupsServices services.AddTransientAs() .AsSelf(); + + services.AddTransientAs() + .As(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs index aa63dae78..cb260f986 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs @@ -73,12 +73,22 @@ public static class TestUtils public static IJsonSerializer CreateSerializer(Action? configure = null) { - var serializerSettings = DefaultOptions(configure); + return CreateSerializer(TypeRegistry, configure); + } + + public static IJsonSerializer CreateSerializer(TypeRegistry typeRegistry, Action? configure = null) + { + var serializerSettings = DefaultOptions(typeRegistry, configure); return new SystemJsonSerializer(serializerSettings); } public static JsonSerializerOptions DefaultOptions(Action? configure = null) + { + return DefaultOptions(TypeRegistry, configure); + } + + public static JsonSerializerOptions DefaultOptions(TypeRegistry typeRegistry, Action? configure = null) { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); @@ -89,10 +99,10 @@ public static class TestUtils options.Converters.Add(new GeoJsonConverterFactory()); options.Converters.Add(new HeaderValueConverter()); options.Converters.Add(new JsonValueConverter()); - options.Converters.Add(new PolymorphicConverter(TypeRegistry)); - options.Converters.Add(new PolymorphicConverter(TypeRegistry)); - options.Converters.Add(new PolymorphicConverter(TypeRegistry)); - options.Converters.Add(new PolymorphicConverter(TypeRegistry)); + options.Converters.Add(new PolymorphicConverter(typeRegistry)); + options.Converters.Add(new PolymorphicConverter(typeRegistry)); + options.Converters.Add(new PolymorphicConverter(typeRegistry)); + options.Converters.Add(new PolymorphicConverter(typeRegistry)); options.Converters.Add(new ReadonlyDictionaryConverterFactory()); options.Converters.Add(new ReadonlyListConverterFactory()); options.Converters.Add(new StringConverter()); @@ -117,11 +127,11 @@ public static class TestUtils options.Converters.Add(new JsonStringEnumConverter()); options.IncludeFields = true; options.TypeInfoResolver = new DefaultJsonTypeInfoResolver() - .WithAddedModifier(PolymorphicConverter.Modifier(TypeRegistry)) + .WithAddedModifier(PolymorphicConverter.Modifier(typeRegistry)) .WithAddedModifier(JsonIgnoreReadonlyProperties.Modifier()) .WithAddedModifier(JsonRenameAttribute.Modifier); #pragma warning disable CS0618 // Type or member is obsolete - options.Converters.Add(new PolymorphicConverter(TypeRegistry)); + options.Converters.Add(new PolymorphicConverter(typeRegistry)); #pragma warning restore CS0618 // Type or member is obsolete configure?.Invoke(options); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs index 564a4cf86..5e7b01482 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs @@ -20,6 +20,7 @@ public class BackupReaderWriterTests { private readonly IEventFormatter eventFormatter; private readonly IEventStreamNames eventStreamNames = A.Fake(); + private readonly IEventMigrator eventMigrator = A.Fake(); private readonly IJsonSerializer serializer = TestUtils.DefaultSerializer; private readonly TypeRegistry typeRegistry = new TypeRegistry(); @@ -34,6 +35,9 @@ public class BackupReaderWriterTests typeRegistry.Add("MyEvent"); eventFormatter = new DefaultEventFormatter(typeRegistry, serializer); + + A.CallTo(() => eventMigrator.MigrateEvent(A._, A._)) + .Returns(null); } [Fact] @@ -63,16 +67,15 @@ public class BackupReaderWriterTests [Fact] public async Task Should_return_true_if_file_exists() { - var file = "File.json"; - - var value = Guid.NewGuid(); + var fileName = "File.json"; + var fileData = Guid.NewGuid(); await TestReaderWriterAsync(BackupVersion.V1, async writer => { - await WriteJsonGuidAsync(writer, file, value); + await WriteJsonGuidAsync(writer, fileName, fileData); }, async reader => { - var hasFile = await reader.HasFileAsync(file); + var hasFile = await reader.HasFileAsync(fileName); Assert.True(hasFile); }); @@ -81,16 +84,15 @@ public class BackupReaderWriterTests [Fact] public async Task Should_return_file_if_file_does_not_exist() { - var file = "File.json"; - - var value = Guid.NewGuid(); + var fileName = "File.json"; + var fileData = Guid.NewGuid(); await TestReaderWriterAsync(BackupVersion.V1, async writer => { await Task.Yield(); }, async reader => { - var hasFile = await reader.HasFileAsync(file); + var hasFile = await reader.HasFileAsync(fileName); Assert.False(hasFile); }); @@ -99,36 +101,34 @@ public class BackupReaderWriterTests [Fact] public async Task Should_read_and_write_json_async() { - var file = "File.json"; - - var value = Guid.NewGuid(); + var fileName = "File.json"; + var fileData = Guid.NewGuid(); await TestReaderWriterAsync(BackupVersion.V1, async writer => { - await WriteJsonGuidAsync(writer, file, value); + await WriteJsonGuidAsync(writer, fileName, fileData); }, async reader => { - var read = await ReadJsonGuidAsync(reader, file); + var read = await ReadJsonGuidAsync(reader, fileName); - Assert.Equal(value, read); + Assert.Equal(fileData, read); }); } [Fact] public async Task Should_read_and_write_blob_async() { - var file = "File.json"; - - var value = Guid.NewGuid(); + var fileName = "File.json"; + var fileData = Guid.NewGuid(); await TestReaderWriterAsync(BackupVersion.V1, async writer => { - await WriteGuidAsync(writer, file, value); + await WriteGuidAsync(writer, fileName, fileData); }, async reader => { - var read = await ReadGuidAsync(reader, file); + var read = await ReadGuidAsync(reader, fileName); - Assert.Equal(value, read); + Assert.Equal(fileData, read); }); } @@ -178,7 +178,6 @@ public class BackupReaderWriterTests for (var i = 0; i < 200; i++) { var @event = new MyEvent(); - var envelope = Envelope.Create(@event); envelope.Headers.Add("Id", @event.Id.ToString()); @@ -211,7 +210,7 @@ public class BackupReaderWriterTests { var targetEvents = new List<(string Stream, Envelope Event)>(); - await foreach (var @event in reader.ReadEventsAsync(eventStreamNames, eventFormatter)) + await foreach (var @event in reader.ReadEventsAsync(eventStreamNames, eventFormatter, [eventMigrator])) { var index = int.Parse(@event.Event.Headers["Index"].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEventMigratorTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEventMigratorTests.cs new file mode 100644 index 000000000..d45aadf7b --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEventMigratorTests.cs @@ -0,0 +1,172 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Extensions.Actions; +using Squidex.Extensions.Actions.Script; +using Squidex.Extensions.Actions.Webhook; +using Squidex.Flows; +using Squidex.Flows.Internal; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Rules; + +public class RuleEventMigratorTests +{ + private readonly TypeRegistry typeRegistrySerializer = new TypeRegistry(); + private readonly TypeRegistry typeRegistryMigrator = new TypeRegistry(); + private readonly IJsonSerializer serializer; + private readonly RuleEventMigrator sut; + + public RuleEventMigratorTests() + { + typeRegistryMigrator.Add("RuleCreated"); + typeRegistryMigrator.Add("RuleUpdated"); + typeRegistryMigrator.Add("Script"); + + typeRegistrySerializer.Add("Script"); + typeRegistrySerializer.Add("Webhook"); + + // Create the serializer after the types have been registered. + serializer = TestUtils.CreateSerializer(typeRegistrySerializer); + + sut = new RuleEventMigrator(typeRegistryMigrator, serializer); + } + + [Fact] + public void Should_not_migrate_rule_created_event_if_flow_step_is_known() + { + typeRegistryMigrator.Add("Webhook"); + + var @event = new RuleCreated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new WebhookFlowStep(), + }, + }, + }, + }; + + var json = serializer.Serialize(@event, true); + + var result = sut.MigrateEvent(typeRegistryMigrator.GetName(@event.GetType()), json); + + Assert.Null(result); + } + + [Fact] + public void Should_migrate_rule_created_event() + { + var @event = new RuleCreated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new WebhookFlowStep(), + }, + }, + }, + }; + + var json = serializer.Serialize(@event, true); + + var resultJson = sut.MigrateEvent(typeRegistryMigrator.GetName(@event.GetType()), json); + var resultEvent = serializer.Deserialize(resultJson!); + + resultEvent.Should().BeEquivalentTo( + new RuleCreated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new WebhookFlowStep(), + }, + }, + }, + }, + options => options.RespectingDeclaredTypes()); + } + + [Fact] + public void Should_not_migrate_rule_updated_event_if_flow_step_is_known() + { + typeRegistryMigrator.Add("Webhook"); + + var @event = new RuleUpdated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new WebhookFlowStep(), + }, + }, + }, + }; + + var json = serializer.Serialize(@event, true); + + var result = sut.MigrateEvent(typeRegistryMigrator.GetName(@event.GetType()), json); + + Assert.Null(result); + } + + [Fact] + public void Should_migrate_rule_updated_event() + { + var @event = new RuleUpdated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new WebhookFlowStep(), + }, + }, + }, + }; + + var json = serializer.Serialize(@event, true); + + var resultJson = sut.MigrateEvent(typeRegistryMigrator.GetName(@event.GetType()), json); + var resultEvent = serializer.Deserialize(resultJson!); + + resultEvent.Should().BeEquivalentTo( + new RuleUpdated + { + Flow = new FlowDefinition + { + Steps = new Dictionary + { + [Guid.Empty] = new FlowStepDefinition + { + Step = new ScriptFlowStep(), + }, + }, + }, + }, + options => options.RespectingDeclaredTypes()); + } +} diff --git a/frontend/src/app/shared/components/search/search-form.component.ts b/frontend/src/app/shared/components/search/search-form.component.ts index b87099e06..9c6f887ab 100644 --- a/frontend/src/app/shared/components/search/search-form.component.ts +++ b/frontend/src/app/shared/components/search/search-form.component.ts @@ -8,9 +8,9 @@ import { AsyncPipe } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, Output, Type } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { BooleanValue, BootstrapClasses, EMPTY_FILTER_MODEL, FieldComponent, FilterField, Input as FilterInput, FilterModel, FilterOptions, NumberValue, StringValue } from 'ngx-inline-filter'; +import { BooleanValue, BootstrapClasses, EMPTY_FILTER_MODEL, FieldComponent, FilterField, Input as FilterInput, FilterModel, FilterOptions, NumberValue, SelectValue, StringValue } from 'ngx-inline-filter'; import { Observable } from 'rxjs'; -import { ControlErrorsComponent, DateTimeEditorComponent, DropdownComponent, FocusOnInitDirective, HighlightPipe, LocalizerService, ModalDialogComponent, ModalDirective, ShortcutComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/framework'; +import { ControlErrorsComponent, DateTimeEditorComponent, DropdownComponent, FocusOnInitDirective, HighlightPipe, LocalizerService, ModalDialogComponent, ModalDirective, ShortcutComponent, TooltipDirective, TourStepDirective, TranslatePipe, Types } from '@app/framework'; import { AppLanguageDto, ContributorsState, DialogModel, Queries, Query, QueryModel, sanitize, SaveQueryForm, TypedSimpleChanges } from '@app/shared/internal'; import { UserDtoPicture } from '../pipes'; import { ReferenceInputComponent } from '../references/reference-input.component'; @@ -170,6 +170,9 @@ export class SearchFormComponent { args = { editor: 'Status', statuses: model.statuses }; } else if (type === 'String' && extra?.editor === 'User') { args = { editor: 'User' }; + } else if (type === 'String' && Types.isArrayOfString(extra?.options)) { + args = (extra.options as string[]).map(value => ({ value, label: value })); + component = SelectValue; } else if (type === 'String' && !extra) { component = StringValue; } else if (type === 'StringArray' && extra?.schemaIds) {