Browse Source

Merge branch 'release/4.x'

# Conflicts:
#	backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
#	backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs
#	backend/src/Squidex.Web/Services/UrlGenerator.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs
#	backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
pull/590/head
Sebastian 5 years ago
parent
commit
8e6a788a27
  1. 26
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs
  2. 104
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  3. 6
      backend/i18n/frontend_en.json
  4. 4
      backend/i18n/frontend_it.json
  5. 2
      backend/i18n/frontend_nl.json
  6. 6
      backend/i18n/source/frontend_en.json
  7. 2
      backend/i18n/source/frontend_it.json
  8. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionProperty.cs
  9. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionPropertyEditor.cs
  10. 127
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleRegistry.cs
  11. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs
  12. 194
      backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs
  13. 17
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  14. 2
      backend/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs
  15. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementPropertyDto.cs
  16. 32
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleElementRegistryTests.cs
  17. 116
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs
  18. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
  19. 8
      frontend/app/features/rules/pages/rules/actions/generic-action.component.html
  20. 10
      frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html
  21. 2
      frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html
  22. 3
      frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss
  23. 4
      frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html
  24. 4
      frontend/app/features/settings/pages/clients/client.component.html
  25. 4
      frontend/app/features/settings/pages/clients/client.component.scss
  26. 8
      frontend/app/shared/services/rules.service.spec.ts
  27. 6
      frontend/app/shared/services/rules.service.ts
  28. 20
      frontend/app/shell/pages/internal/profile-menu.component.html
  29. 16
      frontend/app/shell/pages/internal/profile-menu.component.scss
  30. 35
      frontend/app/shell/pages/internal/profile-menu.component.ts
  31. 2
      frontend/app/theme/_bootstrap-vars.scss

26
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 = "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.")]
[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
}
}

104
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<Dictionary<string, string>> ParseHeadersAsync(string headers, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(headers))
{
return null;
}
var headersDictionary = new Dictionary<string, string>();
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<Result> 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<string, string> Headers { get; set; }
}
}

6
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",
@ -663,11 +664,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",

4
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",
@ -663,6 +664,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.",

2
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",
@ -663,6 +664,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.",

6
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",
@ -663,11 +664,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",

2
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.",

2
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; }

1
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,

127
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<DisplayAttribute>();
@ -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<RequiredAttribute>(property) != null || (type.IsValueType && !IsNullable(type))) && type != typeof(bool) && type != typeof(bool?))
if (!IsNullable(property.PropertyType))
{
actionProperty.IsRequired = true;
if (GetDataAttribute<RequiredAttribute>(property) != null)
{
actionProperty.IsRequired = true;
}
if (type.IsValueType && !IsBoolean(type) && !type.IsEnum)
{
actionProperty.IsRequired = true;
}
}
if (property.GetCustomAttribute<FormattableAttribute>() != null)
@ -99,35 +114,45 @@ namespace Squidex.Domain.Apps.Core.HandleRules
actionProperty.IsFormattable = true;
}
var dataType = GetDataAttribute<DataTypeAttribute>(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<DataTypeAttribute>(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);

6
backend/src/Squidex.Domain.Apps.Core.Operations/IUrlGenerator.cs

@ -24,12 +24,14 @@ namespace Squidex.Domain.Apps.Core
string AssetContent(NamedId<DomainId> appId, string idOrSlug);
string AssetContentBase();
string AssetContentBase(string appName);
string BackupsUI(NamedId<DomainId> appId);
string ClientsUI(NamedId<DomainId> appId);
string ContentsUI(NamedId<DomainId> appId);
string ContentsUI(NamedId<DomainId> appId, NamedId<DomainId> schemaId);
string ContentUI(NamedId<DomainId> appId, NamedId<DomainId> schemaId, DomainId contentId);

194
backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs

@ -8,43 +8,210 @@
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<string, IJsonValue> 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<DomainId, HashSet<DomainId>> contentIdsBySchemaId = new Dictionary<DomainId, HashSet<DomainId>>();
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<IEvent> @event, BackupContext context)
{
if (@event.Payload is AppCreated appCreated)
{
var urls = GetUrls(appCreated.Name);
await context.Writer.WriteJsonAsync(UrlsFile, urls);
}
}
public Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
public async Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
<<<<<<< HEAD
case ContentCreated contentCreated:
contentIdsBySchemaId.GetOrAddNew(contentCreated.SchemaId.Id).Add(@event.Headers.AggregateId());
=======
case AppCreated appCreated:
assetsUrlNew = GetUrls(appCreated.Name);
assetsUrlOld = await ReadUrlsAsync(context.Reader);
>>>>>>> release/4.x
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 true;
}
private void ReplaceAssetUrl(NamedContentData data)
{
foreach (var field in data.Values)
{
if (field != null)
{
ReplaceAssetUrl(field, FieldSetter);
}
}
}
private void ReplaceAssetUrl(IReadOnlyDictionary<string, IJsonValue> 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;
}
}
return Task.FromResult(true);
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)
@ -56,5 +223,26 @@ namespace Squidex.Domain.Apps.Entities.Contents
await rebuilder.InsertManyAsync<ContentDomainObject, ContentState>(ids);
}
}
private async Task<Urls?> ReadUrlsAsync(IBackupReader reader)
{
try
{
return await reader.ReadJsonAttachmentAsync<Urls>(UrlsFile);
}
catch
{
return null;
}
}
private Urls GetUrls(string appName)
{
return new Urls
{
Assets = urlGenerator.AssetContentBase(),
AssetsApp = urlGenerator.AssetContentBase(appName)
};
}
}
}

17
backend/src/Squidex.Web/Services/UrlGenerator.cs

@ -47,6 +47,16 @@ namespace Squidex.Web.Services
return urlsOptions.BuildUrl($"app/{appId.Name}/settings", false);
}
public string AssetContentBase()
{
return urlsOptions.BuildUrl("api/assets/");
}
public string AssetContentBase(string appName)
{
return urlsOptions.BuildUrl($"api/assets/{appName}/");
}
public string AssetContent(NamedId<DomainId> appId, DomainId assetId)
{
return urlsOptions.BuildUrl($"api/assets/{appId.Name}/{assetId}");
@ -54,7 +64,7 @@ namespace Squidex.Web.Services
public string AssetContent(NamedId<DomainId> appId, string idOrSlug)
{
return urlsOptions.BuildUrl($"assets/{appId.Name}/{idOrSlug}");
return urlsOptions.BuildUrl($"api/assets/{appId.Name}/{idOrSlug}");
}
public string? AssetSource(NamedId<DomainId> appId, DomainId assetId, long fileVersion)
@ -67,6 +77,11 @@ namespace Squidex.Web.Services
return urlsOptions.BuildUrl($"app/{appId.Name}/assets", false) + query != null ? $"?query={query}" : string.Empty;
}
public string AssetsUI(NamedId<Named> appId, string? query = null)
{
return urlsOptions.BuildUrl($"app/{appId.Name}/assets?query={query}", false);
}
public string BackupsUI(NamedId<DomainId> appId)
{
return urlsOptions.BuildUrl($"app/{appId.Name}/settings/backups", false);

2
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<NewsEntity, FeatureDto> client;

5
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; }
/// <summary>
/// The options, if the editor is a dropdown.
/// </summary>
public string[]? Options { get; set; }
/// <summary>
/// The optional description.
/// </summary>

32
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 = "<svg></svg>",
@ -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",

116
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs

@ -9,13 +9,17 @@ 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
@ -23,12 +27,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
public class BackupContentsTests
{
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly Rebuilder rebuilder = A.Fake<Rebuilder>();
private readonly BackupContents sut;
public BackupContentsTests()
{
sut = new BackupContents(rebuilder);
sut = new BackupContents(rebuilder, urlGenerator);
}
[Fact]
@ -37,9 +42,116 @@ 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 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(appId.Name))
.Returns(assetsUrlApp);
var writer = A.Fake<IBackupWriter>();
var context = new BackupContext(appId.Id, new UserMapping(me), writer);
await sut.BackupEventAsync(Envelope.Create(new AppCreated
{
Name = appId.Name
}), context);
A.CallTo(() => writer.WriteJsonAsync(A<string>._,
A<BackupContents.Urls>.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 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<IBackupReader>();
A.CallTo(() => urlGenerator.AssetContentBase())
.Returns(newAssetsUrl);
A.CallTo(() => urlGenerator.AssetContentBase(appId.Name))
.Returns(newAssetsUrlApp);
A.CallTo(() => reader.ReadJsonAttachmentAsync<BackupContents.Urls>(A<string>._))
.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.Id, new UserMapping(me), reader, DomainId.NewGuid());
await sut.RestoreEventAsync(Envelope.Create(new AppCreated
{
Name = appId.Name
}), 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 schemaId1 = NamedId.Of(DomainId.NewGuid(), "my-schema1");
var schemaId2 = NamedId.Of(DomainId.NewGuid(), "my-schema2");
@ -47,7 +159,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var contentId2 = DomainId.NewGuid();
var contentId3 = DomainId.NewGuid();
var context = new RestoreContext(appId.Id, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake<IBackupReader>(), DomainId.NewGuid());
var context = new RestoreContext(appId.Id, new UserMapping(me), A.Fake<IBackupReader>(), DomainId.NewGuid());
await sut.RestoreEventAsync(ContentEvent(new ContentCreated
{

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs

@ -46,6 +46,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<DomainId> appId)
{
throw new NotSupportedException();

8
frontend/app/features/rules/pages/rules/actions/generic-action.component.html

@ -13,12 +13,18 @@
</ng-container>
<ng-container *ngSwitchCase="'Checkbox'">
<div class="custom-control custom-checkbox">
<input class="custom-control-input" type="checkbox" id="{{property.name}}" [formControlName]="property.name">
<input class="custom-control-input" type="checkbox" id="{{property.name}}">
<label class="custom-control-label" for="{{property.name}}">
{{property.display}}
</label>
</div>
</ng-container>
<ng-container *ngSwitchCase="'Dropdown'">
<select class="custom-select" [formControlName]="property.name">
<option></option>
<option *ngFor="let option of property.options" [ngValue]="option">{{option}}</option>
</select>
</ng-container>
<ng-container *ngSwitchDefault>
<input type="{{property.editor | lowercase}}" class="form-control" id="{{property.name}}" [formControlName]="property.name">
</ng-container>

10
frontend/app/features/schemas/pages/schema/fields/forms/field-form-ui.component.html

@ -1,5 +1,5 @@
<div [formGroup]="fieldForm">
<div class="form-group row mb-2">
<div class="form-group row mb-3">
<label class="col-3 col-form-label" for="{{field.fieldId}}_editorUrl">{{ 'schemas.field.editorUrl' | sqxTranslate }}</label>
<div class="col-6">
@ -43,14 +43,18 @@
</ng-container>
<div [formGroup]="fieldForm">
<div class="form-group row mt-2">
<div class="col-9 offset-3">
<div class="form-group row mt-3">
<div class="col-6 offset-3">
<div class="custom-control custom-checkbox">
<input class="custom-control-input" type="checkbox" id="{{field.fieldId}}_fieldHalfWidth" formControlName="isHalfWidth">
<label class="custom-control-label" for="{{field.fieldId}}_fieldHalfWidth">
{{ 'schemas.field.halfWidth' | sqxTranslate }}
</label>
</div>
<sqx-form-hint>
{{ 'schemas.field.halfWidthHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>
</div>

2
frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.html

@ -1,5 +1,5 @@
<div [formGroup]="fieldForm">
<div class="form-group row">
<div class="form-group row mb-3">
<div class="col-9 offset-3">
<div class="custom-control custom-checkbox">
<input class="custom-control-input" type="checkbox" id="{{field.fieldId}}_fieldRequired" formControlName="isRequired">

3
frontend/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.scss

@ -1,3 +0,0 @@
.form-group {
margin-bottom: .5rem;
}

4
frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html

@ -61,8 +61,10 @@
<input type="text" class="form-control" id="{{field.fieldId}}_fieldDefaultValue" formControlName="defaultValue">
</div>
</div>
<hr />
<div class="form-group row mt-4">
<div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.fieldTypes.string.contentType' | sqxTranslate }}</label>
<div class="col-6">

4
frontend/app/features/settings/pages/clients/client.component.html

@ -85,8 +85,8 @@
<sqx-form-hint>{{ 'clients.apiCallsLimitHint' | sqxTranslate }}</sqx-form-hint>
</div>
<div class="col-auto cell-input" *ngIf="client.canUpdate">
<button class="btn btn-primary" (click)="updateApiCallsLimit()">
<div class="col-auto cell-input cell-button" *ngIf="client.canUpdate">
<button class="btn btn-secondary" (click)="updateApiCallsLimit()">
{{ 'common.save' | sqxTranslate }}
</button>
</div>

4
frontend/app/features/settings/pages/clients/client.component.scss

@ -18,4 +18,8 @@
.cell-input {
padding-right: 0;
}
.cell-button {
padding-left: .25rem;
}

8
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', '<svg path="2" />', 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({

6
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<string>
) {
}
}
@ -228,7 +229,8 @@ export class RulesService {
property.display,
property.description,
property.isFormattable,
property.isRequired
property.isRequired,
property.options
));
actions[key] = new RuleElementDto(

20
frontend/app/shell/pages/internal/profile-menu.component.html

@ -8,16 +8,14 @@
<span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()">
<span class="user">
<img class="user-picture" [src]="snapshot.profileId | sqxUserIdPicture">
<span class="profile-name">{{snapshot.profileDisplayName}}</span>
</span>
</span>
</li>
</ul>
<ng-container *sqxModal="modalMenu;closeAlways:true;onRoot:false">
<ng-container *sqxModal="modalMenu;onRoot:false">
<div class="dropdown-menu" [sqxAnchoredTo]="button" [offset]="10" @fade>
<a class="dropdown-item dropdown-info" [sqxPopupLink]="snapshot.profileUrl">
<a class="dropdown-item dropdown-info" [sqxPopupLink]="snapshot.profileUrl" (click)="modalMenu.hide()">
<div>{{ 'profile.userEmail' | sqxTranslate }}</div>
<strong>{{snapshot.profileEmail}}</strong>
@ -29,10 +27,22 @@
{{ 'common.administration' | sqxTranslate }}
</a>
<a class="dropdown-item" [sqxPopupLink]="snapshot.profileUrl">
<a class="dropdown-item" [sqxPopupLink]="snapshot.profileUrl" (click)="modalMenu.hide()">
{{ 'profile.title' | sqxTranslate }}
</a>
<div class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle" (click)="toggle()">
{{ 'common.language' | sqxTranslate }}
</a>
<div class="dropdown-menu" [class.open]="showSubmenu">
<a class="dropdown-item" *ngFor="let availableLanguage of languages" [class.active]="availableLanguage.code === language" (click)="changeLanguage(availableLanguage.code)">
{{ availableLanguage.name }}
</a>
</div>
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item" (click)="logout()" sqxExternalLink>

16
frontend/app/shell/pages/internal/profile-menu.component.scss

@ -12,11 +12,19 @@
font-weight: normal;
line-height: 1.4rem;
}
}
@media (max-width: 1200px) {
.profile-name {
display: none;
&-menu {
&.open {
display: block;
}
}
&-submenu {
position: relative;
.dropdown-menu {
@include absolute(0, null, null, -10rem);
}
}
}

35
frontend/app/shell/pages/internal/profile-menu.component.ts

@ -6,7 +6,7 @@
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ApiUrlConfig, AuthService, fadeAnimation, ModalModel, StatefulComponent, UIState } from '@app/shared';
import { ApiUrlConfig, AuthService, fadeAnimation, ModalModel, StatefulComponent, UIOptions, UIState } from '@app/shared';
interface State {
// The display name of the user.
@ -34,9 +34,24 @@ interface State {
export class ProfileMenuComponent extends StatefulComponent<State> implements OnInit {
public modalMenu = new ModalModel();
public showSubmenu = false;
public language = this.uiOptions.get('more.culture');
public languages: ReadonlyArray<{ code: string, name: string }> = [{
code: 'en',
name: 'English'
}, {
code: 'nl',
name: 'Hollandske'
}, {
code: 'it',
name: 'Italiano'
}];
constructor(changeDetector: ChangeDetectorRef, apiUrl: ApiUrlConfig,
public readonly uiState: UIState,
private readonly authService: AuthService
public readonly uiOptions: UIOptions,
public readonly authService: AuthService
) {
super(changeDetector, {
profileDisplayName: '',
@ -55,11 +70,25 @@ export class ProfileMenuComponent extends StatefulComponent<State> implements On
const profileEmail = user.email;
const profileDisplayName = user.displayName;
this.next(s => ({ ...s, profileId, profileEmail, profileDisplayName }));
this.next(s => ({ ...s,
profileId,
profileEmail,
profileDisplayName
}));
}
}));
}
public changeLanguage(code: string) {
document.cookie = `.AspNetCore.Culture=c=${code}|uic=${code}`;
location.reload();
}
public toggle() {
this.showSubmenu = !this.showSubmenu;
}
public logout() {
this.authService.logoutRedirect();
}

2
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;

Loading…
Cancel
Save