From f58376769c74c0e74c1b4858b48c4811d0dc974f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 10:56:38 +0200 Subject: [PATCH 1/8] Minor UI fixes. --- backend/i18n/frontend_en.json | 5 +++-- backend/i18n/frontend_it.json | 1 + backend/i18n/frontend_nl.json | 1 + backend/i18n/source/frontend_en.json | 5 +++-- .../schema/fields/forms/field-form-ui.component.html | 10 +++++++--- .../fields/forms/field-form-validation.component.html | 2 +- .../fields/types/string-validation.component.html | 4 +++- frontend/app/theme/_bootstrap-vars.scss | 2 ++ 8 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 035f51931..ef0ab7b06 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -663,11 +663,12 @@ "schemas.field.enable": "Enable in UI", "schemas.field.enabledMarker": "Enabled", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Hidden", "schemas.field.hide": "Hide in API", - "schemas.field.hintsHint": "Describe this schema for documentation and user interfaces.", + "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", - "schemas.field.labelHint": "Display name for documentation and user interfaces.", + "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", "schemas.field.localizableMarker": "localizable", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 8284cdfea..5f363bfdc 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -663,6 +663,7 @@ "schemas.field.enable": "Abilita nella UI", "schemas.field.enabledMarker": "Abilitato", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Nasconsto", "schemas.field.hide": "Nascondi nelle API", "schemas.field.hintsHint": "Descrivi questo schema per la documentazione e le interfacce utente.", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 494565614..3ff18cbad 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -663,6 +663,7 @@ "schemas.field.enable": "Inschakelen in gebruikersinterface", "schemas.field.enabledMarker": "Ingeschakeld", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Verborgen", "schemas.field.hide": "Verbergen in API", "schemas.field.hintsHint": "Beschrijf dit schema voor documentatie en gebruikersinterfaces.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 035f51931..ef0ab7b06 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -663,11 +663,12 @@ "schemas.field.enable": "Enable in UI", "schemas.field.enabledMarker": "Enabled", "schemas.field.halfWidth": "Half Width", + "schemas.field.halfWidthHint": "Shows the field with only the half width when on the edit or create page, when there is enough space.", "schemas.field.hiddenMarker": "Hidden", "schemas.field.hide": "Hide in API", - "schemas.field.hintsHint": "Describe this schema for documentation and user interfaces.", + "schemas.field.hintsHint": "Describe this field for documentation and the UI.", "schemas.field.inlineEditable": "Inline Editable", - "schemas.field.labelHint": "Display name for documentation and user interfaces.", + "schemas.field.labelHint": "Display name for documentation and the UI.", "schemas.field.localizable": "Localizable", "schemas.field.localizableHint": "You can mark the field as localizable. It means that is dependent on the language, for example a city name.", "schemas.field.localizableMarker": "localizable", diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html index 9ce287760..e66cf7103 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html @@ -1,5 +1,5 @@
-
+
@@ -43,14 +43,18 @@
-
-
+
+
+ + + {{ 'schemas.field.halfWidthHint' | sqxTranslate }} +
diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html index f0faa91be..d3a3b57d7 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html @@ -1,5 +1,5 @@
-
+
diff --git a/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html b/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html index 6148d896b..ae4bc8db4 100644 --- a/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html @@ -61,8 +61,10 @@
+ +
-
+
diff --git a/frontend/app/theme/_bootstrap-vars.scss b/frontend/app/theme/_bootstrap-vars.scss index 7ef50b115..c8f0eae58 100644 --- a/frontend/app/theme/_bootstrap-vars.scss +++ b/frontend/app/theme/_bootstrap-vars.scss @@ -10,6 +10,8 @@ $h4-font-size: $font-size-base * 1.1; $h5-font-size: $font-size-base; $h6-font-size: $font-size-base; +$small-font-size: 90%; + $body-bg: $color-background; $border-color: $color-border; From 21667193652bc12b475701fd0c2001f9d2efb2c0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 11:05:02 +0200 Subject: [PATCH 2/8] Code cleanup --- .../schema/fields/forms/field-form-validation.component.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss index 99ca63921..e69de29bb 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss @@ -1,3 +0,0 @@ -.form-group { - margin-bottom: .5rem; -} \ No newline at end of file From fbb3728eef74ddef12a9fbba5d2703c573e1ca81 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 14:57:08 +0200 Subject: [PATCH 3/8] Update version. --- .../Areas/Api/Controllers/News/Service/FeaturesService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs b/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs index a0bbae023..64609ed43 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs @@ -16,7 +16,7 @@ namespace Squidex.Areas.Api.Controllers.News.Service { public sealed class FeaturesService { - private const int FeatureVersion = 11; + private const int FeatureVersion = 13; private readonly QueryContext flatten = QueryContext.Default.Flatten(); private readonly IContentsClient client; From 12a94ad9f8331a8200bbf9a5ea9a55b16db2a7b8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 17:17:08 +0200 Subject: [PATCH 4/8] Fix asset urls in backups. --- .../IUrlGenerator.cs | 4 + .../Contents/BackupContents.cs | 193 +++++++++++++++++- .../src/Squidex.Web/Services/UrlGenerator.cs | 10 + .../Contents/BackupContentsTests.cs | 122 ++++++++++- .../Contents/TestData/FakeUrlGenerator.cs | 10 + 5 files changed, 332 insertions(+), 7 deletions(-) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs index 4b96939f0..79ee47b28 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs @@ -29,6 +29,10 @@ namespace Squidex.Domain.Apps.Core string AssetContent(NamedId appId, string idOrSlug); + string AssetContentBase(); + + string AssetContentBase(string appName); + string BackupsUI(NamedId appId); string ClientsUI(NamedId appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs index 6542477ea..9abf6a6a5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -9,43 +9,205 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; namespace Squidex.Domain.Apps.Entities.Contents { public sealed class BackupContents : IBackupHandler { + private delegate void ObjectSetter(IReadOnlyDictionary obj, string key, IJsonValue value); + + private const string UrlsFile = "Urls.json"; + + private static readonly ObjectSetter JsonSetter = (obj, key, value) => + { + ((JsonObject)obj).Add(key, value); + }; + + private static readonly ObjectSetter FieldSetter = (obj, key, value) => + { + ((ContentFieldData)obj)[key] = value; + }; + private readonly Dictionary> contentIdsBySchemaId = new Dictionary>(); private readonly Rebuilder rebuilder; + private readonly IUrlGenerator urlGenerator; + private Urls? assetsUrlNew; + private Urls? assetsUrlOld; public string Name { get; } = "Contents"; - public BackupContents(Rebuilder rebuilder) + public sealed class Urls + { + public string Assets { get; set; } + + public string AssetsApp { get; set; } + } + + public BackupContents(Rebuilder rebuilder, IUrlGenerator urlGenerator) { Guard.NotNull(rebuilder, nameof(rebuilder)); + Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.rebuilder = rebuilder; + + this.urlGenerator = urlGenerator; + } + + public async Task BackupEventAsync(Envelope @event, BackupContext context) + { + if (@event.Payload is AppCreated appCreated) + { + var urls = GetUrls(appCreated.Name); + + await context.Writer.WriteJsonAsync(UrlsFile, urls); + } } - public Task RestoreEventAsync(Envelope @event, RestoreContext context) + public async Task RestoreEventAsync(Envelope @event, RestoreContext context) { switch (@event.Payload) { - case ContentCreated contentCreated: - contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(contentCreated.ContentId); + case AppCreated appCreated: + assetsUrlNew = GetUrls(appCreated.Name); + assetsUrlOld = await ReadUrlsAsync(context.Reader); break; case SchemaDeleted schemaDeleted: contentIdsBySchemaId.Remove(schemaDeleted.SchemaId.Id); + break; + case ContentCreated contentCreated: + contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(contentCreated.ContentId); + + if (assetsUrlNew != null && assetsUrlOld != null) + { + ReplaceAssetUrl(contentCreated.Data); + } + + break; + case ContentUpdated contentUpdated: + if (assetsUrlNew != null && assetsUrlOld != null) + { + ReplaceAssetUrl(contentUpdated.Data); + } + break; } - return Task.FromResult(true); + return true; + } + + private void ReplaceAssetUrl(NamedContentData data) + { + foreach (var field in data.Values) + { + if (field != null) + { + ReplaceAssetUrl(field, FieldSetter); + } + } + } + + private void ReplaceAssetUrl(IReadOnlyDictionary source, ObjectSetter setter) + { + List<(string, string)>? replacements = null; + + foreach (var (key, value) in source) + { + switch (value) + { + case JsonString s: + { + var newValue = s.Value; + + newValue = newValue.Replace(assetsUrlOld!.AssetsApp, assetsUrlNew!.AssetsApp); + + if (!ReferenceEquals(newValue, s.Value)) + { + replacements ??= new List<(string, string)>(); + replacements.Add((key, newValue)); + break; + } + + newValue = newValue.Replace(assetsUrlOld!.Assets, assetsUrlNew!.Assets); + + if (!ReferenceEquals(newValue, s.Value)) + { + replacements ??= new List<(string, string)>(); + replacements.Add((key, newValue)); + break; + } + } + + break; + + case JsonArray arr: + ReplaceAssetUrl(arr); + break; + + case JsonObject obj: + ReplaceAssetUrl(obj, JsonSetter); + break; + } + } + + if (replacements != null) + { + foreach (var (key, newValue) in replacements) + { + setter(source, key, JsonValue.Create(newValue)); + } + } + } + + private void ReplaceAssetUrl(JsonArray source) + { + for (var i = 0; i < source.Count; i++) + { + var value = source[i]; + + switch (value) + { + case JsonString s: + { + var newValue = s.Value; + + newValue = newValue.Replace(assetsUrlOld!.AssetsApp, assetsUrlNew!.AssetsApp); + + if (!ReferenceEquals(newValue, s.Value)) + { + source[i] = JsonValue.Create(newValue); + break; + } + + newValue = newValue.Replace(assetsUrlOld!.Assets, assetsUrlNew!.Assets); + + if (!ReferenceEquals(newValue, s.Value)) + { + source[i] = JsonValue.Create(newValue); + break; + } + } + + break; + + case JsonArray arr: + break; + + case JsonObject obj: + ReplaceAssetUrl(obj, FieldSetter); + break; + } + } } public async Task RestoreAsync(RestoreContext context) @@ -61,5 +223,26 @@ namespace Squidex.Domain.Apps.Entities.Contents }); } } + + private async Task ReadUrlsAsync(IBackupReader reader) + { + try + { + return await reader.ReadJsonAttachmentAsync(UrlsFile); + } + catch + { + return null; + } + } + + private Urls GetUrls(string appName) + { + return new Urls + { + Assets = urlGenerator.AssetContentBase(), + AssetsApp = urlGenerator.AssetContentBase(appName) + }; + } } } diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index 135eee58b..5fee5d765 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -73,6 +73,16 @@ namespace Squidex.Web.Services return urlsOptions.BuildUrl($"app/{appId.Name}/assets?query={query}", false); } + public string AssetContentBase() + { + return urlsOptions.BuildUrl("api/assets/"); + } + + public string AssetContentBase(string appName) + { + return urlsOptions.BuildUrl($"api/assets/{appName}/"); + } + public string BackupsUI(NamedId appId) { return urlsOptions.BuildUrl($"app/{appId.Name}/settings/backups", false); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs index 531ee2b4c..3561346f3 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs @@ -10,25 +10,30 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FakeItEasy; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Json.Objects; using Xunit; namespace Squidex.Domain.Apps.Entities.Contents { public class BackupContentsTests { + private readonly IUrlGenerator urlGenerator = A.Fake(); private readonly Rebuilder rebuilder = A.Fake(); private readonly BackupContents sut; public BackupContentsTests() { - sut = new BackupContents(rebuilder); + sut = new BackupContents(rebuilder, urlGenerator); } [Fact] @@ -37,9 +42,122 @@ namespace Squidex.Domain.Apps.Entities.Contents Assert.Equal("Contents", sut.Name); } + [Fact] + public async Task Should_write_asset_urls() + { + var me = new RefToken(RefTokenType.Subject, "123"); + + var appId = Guid.NewGuid(); + var appName = "my-app"; + + var assetsUrl = "https://old.squidex.com/api/assets/"; + var assetsUrlApp = "https://old.squidex.com/api/assets/my-app"; + + A.CallTo(() => urlGenerator.AssetContentBase()) + .Returns(assetsUrl); + + A.CallTo(() => urlGenerator.AssetContentBase(appName)) + .Returns(assetsUrlApp); + + var writer = A.Fake(); + + var context = new BackupContext(appId, new UserMapping(me), writer); + + await sut.BackupEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + A.CallTo(() => writer.WriteJsonAsync(A._, + A.That.Matches(x => + x.Assets == assetsUrl && + x.AssetsApp == assetsUrlApp))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_replace_asset_url_in_content() + { + var me = new RefToken(RefTokenType.Subject, "123"); + + var appId = Guid.NewGuid(); + var appName = "my-new-app"; + + var newAssetsUrl = "https://new.squidex.com/api/assets"; + var newAssetsUrlApp = "https://old.squidex.com/api/assets/my-new-app"; + + var oldAssetsUrl = "https://old.squidex.com/api/assets"; + var oldAssetsUrlApp = "https://old.squidex.com/api/assets/my-old-app"; + + var reader = A.Fake(); + + A.CallTo(() => urlGenerator.AssetContentBase()) + .Returns(newAssetsUrl); + + A.CallTo(() => urlGenerator.AssetContentBase(appName)) + .Returns(newAssetsUrlApp); + + A.CallTo(() => reader.ReadJsonAttachmentAsync(A._)) + .Returns(new BackupContents.Urls + { + Assets = oldAssetsUrl, + AssetsApp = oldAssetsUrlApp + }); + + var data = + new NamedContentData() + .AddField("asset", + new ContentFieldData() + .AddValue("en", $"Asset: {oldAssetsUrlApp}/my-asset.jpg.") + .AddValue("it", $"Asset: {oldAssetsUrl}/my-asset.jpg.")) + .AddField("assetsInArray", + new ContentFieldData() + .AddValue("iv", + JsonValue.Array( + $"Asset: {oldAssetsUrlApp}/my-asset.jpg."))) + .AddField("assetsInObj", + new ContentFieldData() + .AddValue("iv", + JsonValue.Object() + .Add("asset", $"Asset: {oldAssetsUrlApp}/my-asset.jpg."))); + + var updateData = + new NamedContentData() + .AddField("asset", + new ContentFieldData() + .AddValue("en", $"Asset: {newAssetsUrlApp}/my-asset.jpg.") + .AddValue("it", $"Asset: {newAssetsUrl}/my-asset.jpg.")) + .AddField("assetsInArray", + new ContentFieldData() + .AddValue("iv", + JsonValue.Array( + $"Asset: {newAssetsUrlApp}/my-asset.jpg."))) + .AddField("assetsInObj", + new ContentFieldData() + .AddValue("iv", + JsonValue.Object() + .Add("asset", $"Asset: {newAssetsUrlApp}/my-asset.jpg."))); + + var context = new RestoreContext(appId, new UserMapping(me), reader); + + await sut.RestoreEventAsync(Envelope.Create(new AppCreated + { + Name = appName + }), context); + + await sut.RestoreEventAsync(Envelope.Create(new ContentUpdated + { + Data = data + }), context); + + Assert.Equal(updateData, data); + } + [Fact] public async Task Should_restore_states_for_all_contents() { + var me = new RefToken(RefTokenType.Subject, "123"); + var appId = Guid.NewGuid(); var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1"); @@ -49,7 +167,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var contentId2 = Guid.NewGuid(); var contentId3 = Guid.NewGuid(); - var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake()); + var context = new RestoreContext(appId, new UserMapping(me), A.Fake()); await sut.RestoreEventAsync(Envelope.Create(new ContentCreated { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs index cad98469d..07472a725 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -56,6 +56,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData throw new NotSupportedException(); } + public string AssetContentBase() + { + throw new NotSupportedException(); + } + + public string AssetContentBase(string appName) + { + throw new NotSupportedException(); + } + public string BackupsUI(NamedId appId) { throw new NotSupportedException(); From 24bb5d4a9daabb56c03f718964efeafd2668f7ac Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 17:54:06 +0200 Subject: [PATCH 5/8] Language toggle. --- backend/i18n/frontend_en.json | 1 + backend/i18n/frontend_it.json | 3 +- backend/i18n/frontend_nl.json | 1 + backend/i18n/source/frontend_en.json | 1 + backend/i18n/source/frontend_it.json | 2 +- .../internal/profile-menu.component.html | 20 ++++++++--- .../internal/profile-menu.component.scss | 16 ++++++--- .../pages/internal/profile-menu.component.ts | 35 +++++++++++++++++-- 8 files changed, 65 insertions(+), 14 deletions(-) diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index ef0ab7b06..c5655d7a0 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -254,6 +254,7 @@ "common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.", "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.label": "Label", + "common.language": "Language", "common.languages": "Languages", "common.latitudeShort": "Lat", "common.loading": "Loading", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 5f363bfdc..4176a6bf6 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -21,7 +21,7 @@ "apps.createBlankApp": "Nuova App.", "apps.createBlankAppDescription": "Crea una app vuota senza contenuti o schema.", "apps.createBlogApp": "Nuovo blog", - "apps.createBlogAppDescription": "Inizia con il nostro blog già pronto per l'uso.", + "apps.createBlogAppDescription": "Inizia con un blog.", "apps.createFailed": "Non è stato possibile creare l'app. Per favore ricarica.", "apps.createIdentityApp": "Nuova Identity App", "apps.createIdentityAppDescription": "Crea un app per Squidex Identity.", @@ -254,6 +254,7 @@ "common.httpConflict": "Non è stato possibile effettuare l'aggiornamento. Un altro utente ha fatto delle modifiche. Per favore ricarica.", "common.httpLimit": "Hai superato il limite massimo di chiamate API.", "common.label": "Etichetta", + "common.language": "Language", "common.languages": "Lingue", "common.latitudeShort": "Lat", "common.loading": "Caricamento", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 3ff18cbad..a90523bcb 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -254,6 +254,7 @@ "common.httpConflict": "De update is mislukt. Een andere gebruiker heeft een wijziging aangebracht. Laad opnieuw.", "common.httpLimit": "Je hebt de maximale limiet van API-aanroepen overschreden.", "common.label": "Label", + "common.language": "Language", "common.languages": "Talen", "common.latitudeShort": "Lat", "common.loading": "Laden", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index ef0ab7b06..c5655d7a0 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -254,6 +254,7 @@ "common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.", "common.httpLimit": "You have exceeded the maximum limit of API calls.", "common.label": "Label", + "common.language": "Language", "common.languages": "Languages", "common.latitudeShort": "Lat", "common.loading": "Loading", diff --git a/backend/i18n/source/frontend_it.json b/backend/i18n/source/frontend_it.json index a27b7c9e1..a7db499d6 100644 --- a/backend/i18n/source/frontend_it.json +++ b/backend/i18n/source/frontend_it.json @@ -21,7 +21,7 @@ "apps.createBlankApp": "Nuova App.", "apps.createBlankAppDescription": "Crea una app vuota senza contenuti o schema.", "apps.createBlogApp": "Nuovo blog", - "apps.createBlogAppDescription": "Inizia con il nostro blog già pronto per l'uso.", + "apps.createBlogAppDescription": "Inizia con un blog.", "apps.createFailed": "Non è stato possibile creare l'app. Per favore ricarica.", "apps.createIdentityApp": "Nuova Identity App", "apps.createIdentityAppDescription": "Crea un app per Squidex Identity.", diff --git a/frontend/app/shell/pages/internal/profile-menu.component.html b/frontend/app/shell/pages/internal/profile-menu.component.html index 1ff7839ca..6c58a9fb8 100644 --- a/frontend/app/shell/pages/internal/profile-menu.component.html +++ b/frontend/app/shell/pages/internal/profile-menu.component.html @@ -8,16 +8,14 @@ - - {{snapshot.profileDisplayName}} - + -
-
diff --git a/frontend/app/features/settings/pages/clients/client.component.scss b/frontend/app/features/settings/pages/clients/client.component.scss index 29d4f11cc..50ae688c7 100644 --- a/frontend/app/features/settings/pages/clients/client.component.scss +++ b/frontend/app/features/settings/pages/clients/client.component.scss @@ -18,4 +18,8 @@ .cell-input { padding-right: 0; +} + +.cell-button { + padding-left: .25rem; } \ No newline at end of file From 94852f12d8807fe309fea18e498cfa7e3d4c453a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 20:44:24 +0200 Subject: [PATCH 7/8] More options for webhooks. --- .../Actions/Webhook/WebhookAction.cs | 26 +++- .../Actions/Webhook/WebhookActionHandler.cs | 104 ++++++++++++-- .../HandleRules/RuleActionProperty.cs | 2 + .../HandleRules/RuleActionPropertyEditor.cs | 1 + .../HandleRules/RuleRegistry.cs | 127 ++++++++++++++---- .../Rules/Models/RuleElementPropertyDto.cs | 5 + .../HandleRules/RuleElementRegistryTests.cs | 32 +++++ .../actions/generic-action.component.html | 8 +- .../app/shared/services/rules.service.spec.ts | 8 +- frontend/app/shared/services/rules.service.ts | 6 +- 10 files changed, 268 insertions(+), 51 deletions(-) diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs index 954c74a2d..2ddf43463 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs @@ -28,13 +28,33 @@ namespace Squidex.Extensions.Actions.Webhook [Formattable] public Uri Url { get; set; } - [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the signature.")] - [DataType(DataType.Text)] - public string SharedSecret { get; set; } + [LocalizedRequired] + [Display(Name = "Url", Description = "The type of the request.")] + public WebhookMethod Method { get; set; } [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] [DataType(DataType.MultilineText)] [Formattable] public string Payload { get; set; } + + [Display(Name = "Payload Type", Description = "The mime type of the payload.")] + [DataType(DataType.Text)] + public string PayloadType { get; set; } + + [Display(Name = "Headers (Optional)", Description = "The message headers in the format '[Key]=[Value]', one entry per line.")] + [DataType(DataType.MultilineText)] + [Formattable] + public string Headers { get; set; } + + [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the payload signature.")] + [DataType(DataType.Text)] + public string SharedSecret { get; set; } + } + + public enum WebhookMethod + { + POST, + PUT, + GET } } diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs index 25fc872a8..57789717b 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; @@ -29,15 +30,21 @@ namespace Squidex.Extensions.Actions.Webhook protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(EnrichedEvent @event, WebhookAction action) { - string requestBody; + var requestBody = string.Empty; + var requestSignature = string.Empty; - if (!string.IsNullOrEmpty(action.Payload)) + if (action.Method != WebhookMethod.GET) { - requestBody = await FormatAsync(action.Payload, @event); - } - else - { - requestBody = ToEnvelopeJson(@event); + if (!string.IsNullOrEmpty(action.Payload)) + { + requestBody = await FormatAsync(action.Payload, @event); + } + else + { + requestBody = ToEnvelopeJson(@event); + } + + requestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(); } var requestUrl = await FormatAsync(action.Url, @event); @@ -45,27 +52,90 @@ namespace Squidex.Extensions.Actions.Webhook var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleJob = new WebhookJob { + Method = action.Method, RequestUrl = await FormatAsync(action.Url.ToString(), @event), - RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), - RequestBody = requestBody + RequestSignature = requestSignature, + RequestBody = requestBody, + RequestBodyType = action.PayloadType, + Headers = await ParseHeadersAsync(action.Headers, @event) }; return (ruleDescription, ruleJob); } + private async Task> ParseHeadersAsync(string headers, EnrichedEvent @event) + { + if (string.IsNullOrWhiteSpace(headers)) + { + return null; + } + + var headersDictionary = new Dictionary(); + + var lines = headers.Split('\n'); + + foreach (var line in lines) + { + var indexEqual = line.IndexOf('='); + + if (indexEqual > 0 && indexEqual < line.Length - 1) + { + var key = line.Substring(0, indexEqual); + var val = line.Substring(indexEqual + 1); + + val = await FormatAsync(val, @event); + + headersDictionary[key] = val; + } + } + + return headersDictionary; + } + protected override async Task ExecuteJobAsync(WebhookJob job, CancellationToken ct = default) { using (var httpClient = httpClientFactory.CreateClient()) { - using (var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + var method = HttpMethod.Post; + + switch (job.Method) { - Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") - }) + case WebhookMethod.PUT: + method = HttpMethod.Put; + break; + case WebhookMethod.GET: + method = HttpMethod.Get; + break; + } + + var request = new HttpRequestMessage(method, job.RequestUrl); + + if (!string.IsNullOrEmpty(job.RequestBody) && job.Method != WebhookMethod.GET) + { + var mediaType = job.RequestBodyType.Or("application/json"); + + request.Content = new StringContent(job.RequestBody, Encoding.UTF8, mediaType); + } + + using (request) { - request.Headers.Add("X-Signature", job.RequestSignature); - request.Headers.Add("X-Application", "Squidex Webhook"); request.Headers.Add("User-Agent", "Squidex Webhook"); + if (job.Headers != null) + { + foreach (var (key, value) in job.Headers) + { + request.Headers.TryAddWithoutValidation(key, value); + } + } + + if (!string.IsNullOrWhiteSpace(job.RequestSignature)) + { + request.Headers.Add("X-Signature", job.RequestSignature); + } + + request.Headers.Add("X-Application", "Squidex Webhook"); + return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); } } @@ -74,10 +144,16 @@ namespace Squidex.Extensions.Actions.Webhook public sealed class WebhookJob { + public WebhookMethod Method { get; set; } + public string RequestUrl { get; set; } public string RequestSignature { get; set; } public string RequestBody { get; set; } + + public string RequestBodyType { get; set; } + + public Dictionary Headers { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs index 956314daa..61793f8bc 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules public string? Description { get; set; } + public string[]? Options { get; set; } + public bool IsFormattable { get; set; } public bool IsRequired { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs index 469e01a35..0371def5f 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs @@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules public enum RuleActionPropertyEditor { Checkbox, + Dropdown, Email, Number, Password, diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs index 6a0f6fab6..b1b9c0fab 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs @@ -73,7 +73,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules { if (property.CanRead && property.CanWrite) { - var actionProperty = new RuleActionProperty { Name = property.Name.ToCamelCase(), Display = property.Name }; + var actionProperty = new RuleActionProperty + { + Name = property.Name.ToCamelCase() + }; var display = property.GetCustomAttribute(); @@ -81,17 +84,29 @@ namespace Squidex.Domain.Apps.Core.HandleRules { actionProperty.Display = display.Name; } + else + { + actionProperty.Display = property.Name; + } if (!string.IsNullOrWhiteSpace(display?.Description)) { actionProperty.Description = display.Description; } - var type = property.PropertyType; + var type = GetType(property); - if ((GetDataAttribute(property) != null || (type.IsValueType && !IsNullable(type))) && type != typeof(bool) && type != typeof(bool?)) + if (!IsNullable(property.PropertyType)) { - actionProperty.IsRequired = true; + if (GetDataAttribute(property) != null) + { + actionProperty.IsRequired = true; + } + + if (type.IsValueType && !IsBoolean(type) && !type.IsEnum) + { + actionProperty.IsRequired = true; + } } if (property.GetCustomAttribute() != null) @@ -99,35 +114,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules actionProperty.IsFormattable = true; } - var dataType = GetDataAttribute(property)?.DataType; - - if (type == typeof(bool) || type == typeof(bool?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Checkbox; - } - else if (type == typeof(int) || type == typeof(int?)) - { - actionProperty.Editor = RuleActionPropertyEditor.Number; - } - else if (dataType == DataType.Url) + if (type.IsEnum) { - actionProperty.Editor = RuleActionPropertyEditor.Url; - } - else if (dataType == DataType.Password) - { - actionProperty.Editor = RuleActionPropertyEditor.Password; - } - else if (dataType == DataType.EmailAddress) - { - actionProperty.Editor = RuleActionPropertyEditor.Email; - } - else if (dataType == DataType.MultilineText) - { - actionProperty.Editor = RuleActionPropertyEditor.TextArea; + var values = Enum.GetNames(type); + + actionProperty.Options = values; + actionProperty.Editor = RuleActionPropertyEditor.Dropdown; } else { - actionProperty.Editor = RuleActionPropertyEditor.Text; + var dataType = GetDataAttribute(property)?.DataType; + + if (IsBoolean(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Checkbox; + } + else if (IsNumericType(type)) + { + actionProperty.Editor = RuleActionPropertyEditor.Number; + } + else if (dataType == DataType.Url) + { + actionProperty.Editor = RuleActionPropertyEditor.Url; + } + else if (dataType == DataType.Password) + { + actionProperty.Editor = RuleActionPropertyEditor.Password; + } + else if (dataType == DataType.EmailAddress) + { + actionProperty.Editor = RuleActionPropertyEditor.Email; + } + else if (dataType == DataType.MultilineText) + { + actionProperty.Editor = RuleActionPropertyEditor.TextArea; + } + else + { + actionProperty.Editor = RuleActionPropertyEditor.Text; + } } definition.Properties.Add(actionProperty); @@ -151,6 +176,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); } + private static bool IsBoolean(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + return true; + default: + return false; + } + } + + private static bool IsNumericType(Type type) + { + switch (Type.GetTypeCode(type)) + { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + return true; + default: + return false; + } + } + + private static Type GetType(PropertyInfo property) + { + var type = property.PropertyType; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + type = type.GetGenericArguments()[0]; + } + + return type; + } + private static string GetActionName(Type type) { return type.TypeName(false, ActionSuffix, ActionSuffixV2); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs index f6180f369..529540b1a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs @@ -30,6 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [LocalizedRequired] public string Display { get; set; } + /// + /// The options, if the editor is a dropdown. + /// + public string[]? Options { get; set; } + /// /// The optional description. /// diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs index 3cfca4238..c446ab897 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs @@ -41,6 +41,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules public string Custom { get; set; } } + public enum ActionEnum + { + Yes, + No + } + [RuleAction( Title = "Action", IconImage = "", @@ -68,6 +74,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules [DataType(DataType.Password)] public string Password { get; set; } + [DataType(DataType.Text)] + public ActionEnum Enum { get; set; } + + [DataType(DataType.Text)] + public ActionEnum? EnumOptional { get; set; } + [DataType(DataType.Text)] public bool Boolean { get; set; } @@ -141,6 +153,26 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules IsRequired = false }); + expected.Properties.Add(new RuleActionProperty + { + Name = "enum", + Display = "Enum", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = true, + Options = new[] { "Yes", "No" } + }); + + expected.Properties.Add(new RuleActionProperty + { + Name = "enumOptional", + Display = "EnumOptional", + Description = null, + Editor = RuleActionPropertyEditor.Dropdown, + IsRequired = false, + Options = new[] { "Yes", "No" } + }); + expected.Properties.Add(new RuleActionProperty { Name = "boolean", diff --git a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html index bf34b98c8..5000d2f75 100644 --- a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html +++ b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html @@ -13,12 +13,18 @@
- +
+ + + diff --git a/frontend/app/shared/services/rules.service.spec.ts b/frontend/app/shared/services/rules.service.spec.ts index ff60594e5..3966c6359 100644 --- a/frontend/app/shared/services/rules.service.spec.ts +++ b/frontend/app/shared/services/rules.service.spec.ts @@ -64,7 +64,11 @@ describe('RulesService', () => { display: 'Display2', description: 'Description2', isRequired: false, - isFormattable: true + isFormattable: true, + options: [ + 'Yes', + 'No' + ] }] }, action1: { @@ -82,7 +86,7 @@ describe('RulesService', () => { const action2 = new RuleElementDto('title2', 'display2', 'description2', '#222', '', null, 'link2', [ new RuleElementPropertyDto('property1', 'Editor1', 'Display1', 'Description1', false, true), - new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false) + new RuleElementPropertyDto('property2', 'Editor2', 'Display2', 'Description2', true, false, ['Yes', 'No']) ]); expect(actions!).toEqual({ diff --git a/frontend/app/shared/services/rules.service.ts b/frontend/app/shared/services/rules.service.ts index 56358e7fc..db8f46bc3 100644 --- a/frontend/app/shared/services/rules.service.ts +++ b/frontend/app/shared/services/rules.service.ts @@ -97,7 +97,8 @@ export class RuleElementPropertyDto { public readonly display: string, public readonly description: string, public readonly isFormattable: boolean, - public readonly isRequired: boolean + public readonly isRequired: boolean, + public readonly options?: ReadonlyArray ) { } } @@ -228,7 +229,8 @@ export class RulesService { property.display, property.description, property.isFormattable, - property.isRequired + property.isRequired, + property.options )); actions[key] = new RuleElementDto( From 2758a56486f611be9e83f6a5f1e9d38768de8dc0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2020 20:51:19 +0200 Subject: [PATCH 8/8] Small UI fix. --- .../Squidex.Extensions/Actions/Webhook/WebhookAction.cs | 2 +- .../rules/pages/rules/actions/generic-action.component.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs index 2ddf43463..33bac48cb 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs @@ -29,7 +29,7 @@ namespace Squidex.Extensions.Actions.Webhook public Uri Url { get; set; } [LocalizedRequired] - [Display(Name = "Url", Description = "The type of the request.")] + [Display(Name = "Method", Description = "The type of the request.")] public WebhookMethod Method { get; set; } [Display(Name = "Payload (Optional)", Description = "Leave it empty to use the full event as body.")] diff --git a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html index 5000d2f75..1dc785163 100644 --- a/frontend/app/features/rules/pages/rules/actions/generic-action.component.html +++ b/frontend/app/features/rules/pages/rules/actions/generic-action.component.html @@ -21,8 +21,8 @@