diff --git a/backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs index c2b89fb54..cc3141a46 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.HandleRules; @@ -13,11 +12,10 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; namespace Squidex.Extensions.Actions.Comment { - public sealed class CommentActionHandler : RuleActionHandler + public sealed class CommentActionHandler : RuleActionHandler { private const string Description = "Send a Comment"; private readonly ICommandBus commandBus; @@ -30,56 +28,48 @@ namespace Squidex.Extensions.Actions.Comment this.commandBus = commandBus; } - protected override async Task<(string Description, CommentJob Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action) + protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action) { if (@event is EnrichedContentEvent contentEvent) { - var text = await FormatAsync(action.Text, @event); + var ruleJob = new CreateComment + { + AppId = contentEvent.AppId, + }; - var actor = contentEvent.Actor; + ruleJob.Text = await FormatAsync(action.Text, @event); if (!string.IsNullOrEmpty(action.Client)) { - actor = new RefToken(RefTokenType.Client, action.Client); + ruleJob.Actor = new RefToken(RefTokenType.Client, action.Client); } - - var ruleJob = new CommentJob + else { - AppId = contentEvent.AppId, - Actor = actor, - CommentsId = contentEvent.Id.ToString(), - Text = text - }; + ruleJob.Actor = contentEvent.Actor; + } + + ruleJob.CommentsId = contentEvent.Id.ToString(); return (Description, ruleJob); } - return ("Ignore", new CommentJob()); + return ("Ignore", new CreateComment()); } - protected override async Task ExecuteJobAsync(CommentJob job, CancellationToken ct = default) + protected override async Task ExecuteJobAsync(CreateComment job, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(job.CommentsId)) { return Result.Ignored(); } - var command = SimpleMapper.Map(job, new CreateComment()); + var command = job; + + command.FromRule = true; await commandBus.PublishAsync(command); return Result.Success($"Commented: {job.Text}"); } } - - public sealed class CommentJob - { - public NamedId AppId { get; set; } - - public RefToken Actor { get; set; } - - public string CommentsId { get; set; } - - public string Text { get; set; } - } } diff --git a/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs new file mode 100644 index 000000000..2689a30aa --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentAction.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Extensions.Actions.CreateContent +{ + [RuleAction( + Title = "CreateContent", + IconImage = "", + IconColor = "#3389ff", + Display = "Create content", + Description = "Create a a new content item for any schema.")] + public sealed class CreateContentAction : RuleAction + { + [LocalizedRequired] + [Display(Name = "Data", Description = "The content data.")] + [DataType(DataType.MultilineText)] + [Formattable] + public string Data { get; set; } + + [LocalizedRequired] + [Display(Name = "Schema", Description = "The name of the schema.")] + [DataType(DataType.Text)] + public string Schema { get; set; } + + [Display(Name = "Client", Description = "An optional client name.")] + [DataType(DataType.Text)] + public string Client { get; set; } + + [Display(Name = "Publish", Description = "Publish the content.")] + [DataType(DataType.Text)] + public bool Publish { get; set; } + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs new file mode 100644 index 000000000..3e87610d1 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs @@ -0,0 +1,87 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Json; +using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent; + +namespace Squidex.Extensions.Actions.CreateContent +{ + public sealed class CreateContentActionHandler : RuleActionHandler + { + private const string Description = "Create a content"; + private readonly ICommandBus commandBus; + private readonly IAppProvider appProvider; + private readonly IJsonSerializer jsonSerializer; + + public CreateContentActionHandler(RuleEventFormatter formatter, IAppProvider appProvider, ICommandBus commandBus, IJsonSerializer jsonSerializer) + : base(formatter) + { + Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(commandBus, nameof(commandBus)); + Guard.NotNull(jsonSerializer, nameof(jsonSerializer)); + + this.appProvider = appProvider; + this.commandBus = commandBus; + this.jsonSerializer = jsonSerializer; + } + + protected override async Task<(string Description, Command Data)> CreateJobAsync(EnrichedEvent @event, CreateContentAction action) + { + var ruleJob = new Command + { + AppId = @event.AppId, + }; + + var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true); + + if (schema == null) + { + throw new InvalidOperationException($"Cannot find schema '{action.Schema}'"); + } + + ruleJob.SchemaId = schema.NamedId(); + + var json = await FormatAsync(action.Data, @event); + + ruleJob.Data = jsonSerializer.Deserialize(json); + + if (!string.IsNullOrEmpty(action.Client)) + { + ruleJob.Actor = new RefToken(RefTokenType.Client, action.Client); + } + else if (@event is EnrichedUserEventBase userEvent) + { + ruleJob.Actor = userEvent.Actor; + } + + ruleJob.Publish = action.Publish; + + return (Description, ruleJob); + } + + protected override async Task ExecuteJobAsync(Command job, CancellationToken ct = default) + { + var command = job; + + command.FromRule = true; + + await commandBus.PublishAsync(command); + + return Result.Success($"Created to: {job.SchemaId.Name}"); + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs new file mode 100644 index 000000000..644917108 --- /dev/null +++ b/backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentPlugin.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure.Plugins; + +namespace Squidex.Extensions.Actions.CreateContent +{ + public sealed class CreateContentPlugin : IPlugin + { + public void ConfigureServices(IServiceCollection services, IConfiguration config) + { + services.AddRuleAction(); + } + } +} diff --git a/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs index 0093b2335..f1f3f7655 100644 --- a/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs +++ b/backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs @@ -13,12 +13,11 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Shared.Users; namespace Squidex.Extensions.Actions.Notification { - public sealed class NotificationActionHandler : RuleActionHandler + public sealed class NotificationActionHandler : RuleActionHandler { private const string Description = "Send a Notification"; private static readonly NamedId NoApp = NamedId.Of(Guid.Empty, "none"); @@ -36,7 +35,7 @@ namespace Squidex.Extensions.Actions.Notification this.userResolver = userResolver; } - protected override async Task<(string Description, NotificationJob Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action) + protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action) { if (@event is EnrichedUserEventBase userEvent) { @@ -56,7 +55,7 @@ namespace Squidex.Extensions.Actions.Notification throw new InvalidOperationException($"Cannot find user by '{action.User}'"); } - var ruleJob = new NotificationJob { Actor = actor, CommentsId = user.Id, Text = text }; + var ruleJob = new CreateComment { Actor = actor, CommentsId = user.Id, Text = text }; if (!string.IsNullOrWhiteSpace(action.Url)) { @@ -71,32 +70,24 @@ namespace Squidex.Extensions.Actions.Notification return (Description, ruleJob); } - return ("Ignore", new NotificationJob()); + return ("Ignore", new CreateComment()); } - protected override async Task ExecuteJobAsync(NotificationJob job, CancellationToken ct = default) + protected override async Task ExecuteJobAsync(CreateComment job, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(job.CommentsId)) { return Result.Ignored(); } - var command = SimpleMapper.Map(job, new CreateComment { AppId = NoApp }); + var command = job; + + command.AppId = NoApp; + command.FromRule = true; await commandBus.PublishAsync(command); return Result.Success($"Notified: {job.Text}"); } } - - public sealed class NotificationJob - { - public RefToken Actor { get; set; } - - public string CommentsId { get; set; } - - public string Text { get; set; } - - public Uri Url { get; set; } - } } diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 81e1a1628..fe11289b7 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -58,6 +58,8 @@ "assets.deleteFolderConfirmTitle": "Delete folder", "assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?", "assets.deleteMetadataConfirmTitle": "Remove metadata", + "assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?", + "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Download this Version", "assets.dropToUpdate": "Drop to update", "assets.duplicateFile": "Asset has already been uploaded.", @@ -286,6 +288,7 @@ "common.queryOperators.ne": "is not equals to", "common.queryOperators.startsWith": "starts with", "common.refresh": "Refresh", + "common.remember": "Remember my decision", "common.rename": "Rename", "common.requiredHint": "required", "common.reset": "Reset", @@ -354,6 +357,8 @@ "contents.deleteConfirmTitle": "Delete content", "contents.deleteFailed": "Failed to delete content. Please reload.", "contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", + "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?", + "contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteVersionConfirmText": "Do you really want to delete this version?", "contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.draftNew": "New Draft", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index f5f87d70d..ccc8d5834 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -58,6 +58,8 @@ "assets.deleteFolderConfirmTitle": "Elimina la cartella", "assets.deleteMetadataConfirmText": "Sei sicuro di voler rimuovere questi metadati?", "assets.deleteMetadataConfirmTitle": "Rimuovi metadati", + "assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?", + "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Scarica questa versione", "assets.dropToUpdate": "Trascina il file per aggiornare", "assets.duplicateFile": "La risorsa è già stata caricata.", @@ -286,6 +288,7 @@ "common.queryOperators.ne": "è uguale a", "common.queryOperators.startsWith": "inizia con", "common.refresh": "Aggiorna", + "common.remember": "Remember my decision", "common.rename": "Rinomina", "common.requiredHint": "obbligatorio", "common.reset": "Reimposta", @@ -354,6 +357,8 @@ "contents.deleteConfirmTitle": "Elimina il contenuto", "contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.", "contents.deleteManyConfirmText": "Sei sicuro di voler eliminare gli elementi del contenuto selezionati?", + "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?", + "contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteVersionConfirmText": "Do you really want to delete this version?", "contents.deleteVersionFailed": "Non è stato possibile eliminare la versione. Per favore ricarica.", "contents.draftNew": "Nuova bozza", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 5c67eb6ab..57770b3be 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -58,6 +58,8 @@ "assets.deleteFolderConfirmTitle": "Map verwijderen", "assets.deleteMetadataConfirmText": "Wil je deze metadata echt verwijderen?", "assets.deleteMetadataConfirmTitle": "Metadata verwijderen", + "assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?", + "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Download deze versie", "assets.dropToUpdate": "Zet neer om te updaten", "assets.duplicateFile": "Asset is al geüpload.", @@ -286,6 +288,7 @@ "common.queryOperators.ne": "is not equals to", "common.queryOperators.startsWith": "starts with", "common.refresh": "Vernieuwen", + "common.remember": "Remember my decision", "common.rename": "Hernoemen", "common.requiredHint": "verplicht", "common.reset": "Reset", @@ -354,6 +357,8 @@ "contents.deleteConfirmTitle": "Inhoud verwijderen", "contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.", "contents.deleteManyConfirmText": "Weet je zeker dat je de geselecteerde inhoudsitems wilt verwijderen?", + "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?", + "contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteVersionConfirmText": "Wil je deze versie echt verwijderen?", "contents.deleteVersionFailed": "Verwijderen van versie is mislukt. Laad opnieuw.", "contents.draftNew": "Nieuw concept", diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json index cd11ee5b7..4d6b3dd2c 100644 --- a/backend/i18n/source/backend_en.json +++ b/backend/i18n/source/backend_en.json @@ -33,6 +33,7 @@ "assets.folderNotFound": "Asset folder does not exist.", "assets.folderRecursion": "Cannot add folder to its own child.", "assets.maxSizeReached": "You have reached your max asset size.", + "assets.referenced": "Assets is referenced by a content and cannot be deleted.", "backups.alreadyRunning": "Another backup process is already running.", "backups.maxReached": "You cannot have more than {max} backups.", "backups.restoreRunning": "A restore operation is already running.", @@ -136,6 +137,7 @@ "contents.invalidNumber": "Invalid json type, expected number.", "contents.invalidString": "Invalid json type, expected string.", "contents.listReferences": "{count} Reference(s)", + "contents.referenced": "Content is referenced by another content and cannot be deleted.", "contents.singletonNotChangeable": "Singleton content cannot be updated.", "contents.singletonNotCreatable": "Singleton content cannot be created.", "contents.singletonNotDeletable": "Singleton content cannot be deleted.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 81e1a1628..fe11289b7 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -58,6 +58,8 @@ "assets.deleteFolderConfirmTitle": "Delete folder", "assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?", "assets.deleteMetadataConfirmTitle": "Remove metadata", + "assets.deleteReferrerConfirmText": "The asset is referenced by a content item.\n\nDo you really want to delete the asset?", + "assets.deleteReferrerConfirmTitle": "Delete asset", "assets.downloadVersion": "Download this Version", "assets.dropToUpdate": "Drop to update", "assets.duplicateFile": "Asset has already been uploaded.", @@ -286,6 +288,7 @@ "common.queryOperators.ne": "is not equals to", "common.queryOperators.startsWith": "starts with", "common.refresh": "Refresh", + "common.remember": "Remember my decision", "common.rename": "Rename", "common.requiredHint": "required", "common.reset": "Reset", @@ -354,6 +357,8 @@ "contents.deleteConfirmTitle": "Delete content", "contents.deleteFailed": "Failed to delete content. Please reload.", "contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", + "contents.deleteReferrerConfirmText": "The content is referenced by another content item.\n\nDo you really want to delete the content?", + "contents.deleteReferrerConfirmTitle": "Delete content", "contents.deleteVersionConfirmText": "Do you really want to delete this version?", "contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.draftNew": "New Draft", diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs index 656e81abe..e0408b1df 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/ContentFieldData.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; @@ -88,5 +89,10 @@ namespace Squidex.Domain.Apps.Core.Contents { return this.DictionaryHashCode(); } + + public override string ToString() + { + return $"{{{string.Join(", ", this.Select(x => $"\"{x.Key}\":{x.Value.ToJsonString()}"))}}}"; + } } } \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs index 84ed62235..9eec25ad2 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs @@ -7,6 +7,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Contents @@ -68,5 +69,10 @@ namespace Squidex.Domain.Apps.Core.Contents { return base.Equals(other); } + + public override string ToString() + { + return $"{{{string.Join(", ", this.Select(x => $"\"{x.Key}\":{x.Value}"))}}}"; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index f35b4feb9..1aeb74cd8 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -89,6 +89,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules var typed = @event.To(); + if (typed.Payload.FromRule) + { + return result; + } + var actionType = rule.Action.GetType(); if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs index f57eaca91..813cc2c1a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using Jint; @@ -12,6 +13,7 @@ using Jint.Native; using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Orleans; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; @@ -116,6 +118,11 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper var propertyName = property.AsString(); + if (propertyName.Equals("toJSON", StringComparison.OrdinalIgnoreCase)) + { + return PropertyDescriptor.Undefined; + } + return fieldProperties.GetOrAdd(propertyName, this, (k, c) => new ContentDataProperty(c, new ContentFieldObject(c, new ContentFieldData(), false))); } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs index f69a49fd9..351a73d61 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using Jint; @@ -131,6 +132,11 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper var propertyName = property.AsString(); + if (propertyName.Equals("toJSON", StringComparison.OrdinalIgnoreCase)) + { + return PropertyDescriptor.Undefined; + } + return valueProperties?.GetOrDefault(propertyName) ?? PropertyDescriptor.Undefined; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs new file mode 100644 index 000000000..4e1c0dd2e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using MongoDB.Bson.Serialization; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets +{ + internal static class Fields + { + private static readonly Lazy AssetIdField = new Lazy(GetAssetIdField); + private static readonly Lazy AssetFolderIdField = new Lazy(GetAssetFolderIdField); + + public static string AssetId => AssetIdField.Value; + + public static string AssetFolderId => AssetFolderIdField.Value; + + private static string GetAssetIdField() + { + return BsonClassMap.LookupClassMap(typeof(MongoAssetEntity)).GetMemberMap(nameof(MongoAssetEntity.Id)).ElementName; + } + + private static string GetAssetFolderIdField() + { + return BsonClassMap.LookupClassMap(typeof(MongoAssetFolderEntity)).GetMemberMap(nameof(MongoAssetFolderEntity.Id)).ElementName; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs index ecad09811..904b95ab3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MongoDB.Bson.Serialization; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; @@ -22,8 +21,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase, IAssetFolderRepository { - private static readonly Lazy IdField = new Lazy(GetIdField); - public MongoAssetFolderRepository(IMongoDatabase database) : base(database) { @@ -67,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id) .ToListAsync(); - return assetFolderEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList(); + return assetFolderEntities.Select(x => Guid.Parse(x[Fields.AssetFolderId].AsString)).ToList(); } } @@ -87,10 +84,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets return assetFolderEntity; } } - - private static string GetIdField() - { - return BsonClassMap.LookupClassMap(typeof(MongoAssetFolderEntity)).GetMemberMap(nameof(MongoAssetFolderEntity.Id)).ElementName; - } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 0eddf64c9..ef3e24648 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MongoDB.Bson.Serialization; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; @@ -27,8 +26,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { public sealed partial class MongoAssetRepository : MongoRepositoryBase, IAssetRepository { - private static readonly Lazy IdField = new Lazy(GetIdField); - public MongoAssetRepository(IMongoDatabase database) : base(database) { @@ -105,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.Find(BuildFilter(appId, ids)).Only(x => x.Id) .ToListAsync(); - return assetEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList(); + return assetEntities.Select(x => Guid.Parse(x[Fields.AssetId].AsString)).ToList(); } } @@ -117,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id) .ToListAsync(); - return assetEntities.Select(x => Guid.Parse(x[IdField.Value].AsString)).ToList(); + return assetEntities.Select(x => Guid.Parse(x[Fields.AssetId].AsString)).ToList(); } } @@ -176,10 +173,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets Filter.In(x => x.Id, ids), Filter.Ne(x => x.IsDeleted, true)); } - - private static string GetIdField() - { - return BsonClassMap.LookupClassMap(typeof(MongoAssetEntity)).GetMemberMap(nameof(MongoAssetEntity.Id)).ElementName; - } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs new file mode 100644 index 000000000..7e7e988c8 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using MongoDB.Bson.Serialization; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal static class Fields + { + private static readonly Lazy IdField = new Lazy(GetIdField); + private static readonly Lazy SchemaIdField = new Lazy(GetSchemaIdField); + + public static string Id => IdField.Value; + + public static string SchemaId => SchemaIdField.Value; + + private static string GetIdField() + { + return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName; + } + + private static string GetSchemaIdField() + { + return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs index a07454389..76b165123 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs @@ -30,6 +30,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private readonly QueryContentsByIds queryContentsById; private readonly QueryContentsByQuery queryContentsByQuery; private readonly QueryIdsAsync queryIdsAsync; + private readonly QueryReferrersAsync queryReferrersAsync; private readonly QueryScheduledContents queryScheduledItems; public MongoContentCollectionAll(IMongoDatabase database, IAppProvider appProvider, ITextIndex indexer, DataConverter converter) @@ -39,6 +40,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents queryContentsById = new QueryContentsByIds(converter, appProvider); queryContentsByQuery = new QueryContentsByQuery(converter, indexer); queryIdsAsync = new QueryIdsAsync(appProvider); + queryReferrersAsync = new QueryReferrersAsync(); queryScheduledItems = new QueryScheduledContents(); } @@ -58,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await queryContentsById.PrepareAsync(collection, ct); await queryContentsByQuery.PrepareAsync(collection, ct); await queryIdsAsync.PrepareAsync(collection, ct); + await queryReferrersAsync.PrepareAsync(collection, ct); await queryScheduledItems.PrepareAsync(collection, ct); } @@ -125,6 +128,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents } } + public async Task HasReferrersAsync(Guid contentId) + { + using (Profiler.TraceMethod()) + { + return await queryReferrersAsync.DoAsync(contentId); + } + } + public Task ResetScheduledAsync(Guid id) { return Collection.UpdateOneAsync(x => x.Id == id, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt)); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 6198674f0..f69b64052 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -129,6 +129,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return collectionAll.QueryIdsAsync(appId, schemaId, filterNode); } + public Task HasReferrersAsync(Guid contentId) + { + return collectionAll.HasReferrersAsync(contentId); + } + public IEnumerable> GetInternalCollections() { yield return collectionAll.GetInternalCollection(); diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs index 90f40738c..edac8aac1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; -using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { @@ -16,7 +15,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { protected static readonly SortDefinitionBuilder Sort = Builders.Sort; protected static readonly UpdateDefinitionBuilder Update = Builders.Update; - protected static readonly FieldDefinitionBuilder Fields = FieldDefinitionBuilder.Instance; protected static readonly FilterDefinitionBuilder Filter = Builders.Filter; protected static readonly IndexKeysDefinitionBuilder Index = Builders.IndexKeys; protected static readonly ProjectionDefinitionBuilder Projection = Builders.Projection; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs index 2b0a8e773..c6089c964 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using MongoDB.Bson.Serialization; using MongoDB.Driver; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb.Queries; @@ -21,8 +20,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations internal sealed class QueryIdsAsync : OperationBase { private static readonly List<(Guid SchemaId, Guid Id)> EmptyIds = new List<(Guid SchemaId, Guid Id)>(); - private static readonly Lazy IdField = new Lazy(GetIdField); - private static readonly Lazy SchemaIdField = new Lazy(GetSchemaIdField); private readonly IAppProvider appProvider; public QueryIdsAsync(IAppProvider appProvider) @@ -52,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId) .ToListAsync(); - return contentEntities.Select(x => (Guid.Parse(x[SchemaIdField.Value].AsString), Guid.Parse(x[IdField.Value].AsString))).ToList(); + return contentEntities.Select(x => (Guid.Parse(x[Fields.SchemaId].AsString), Guid.Parse(x[Fields.Id].AsString))).ToList(); } public async Task> DoAsync(Guid appId, Guid schemaId, FilterNode filterNode) @@ -70,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId) .ToListAsync(); - return contentEntities.Select(x => (Guid.Parse(x[SchemaIdField.Value].AsString), Guid.Parse(x[IdField.Value].AsString))).ToList(); + return contentEntities.Select(x => (Guid.Parse(x[Fields.SchemaId].AsString), Guid.Parse(x[Fields.Id].AsString))).ToList(); } public static FilterDefinition BuildFilter(FilterNode? filterNode, Guid schemaId) @@ -88,15 +85,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return Filter.And(filters); } - - private static string GetIdField() - { - return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.Id)).ElementName; - } - - private static string GetSchemaIdField() - { - return BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).GetMemberMap(nameof(MongoContentEntity.IndexedSchemaId)).ElementName; - } } } \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs new file mode 100644 index 000000000..7456f4b53 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations +{ + internal sealed class QueryReferrersAsync : OperationBase + { + protected override Task PrepareAsync(CancellationToken ct = default) + { + var index = + new CreateIndexModel(Index + .Ascending(x => x.ReferencedIds) + .Ascending(x => x.IsDeleted)); + + return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct); + } + + public async Task DoAsync(Guid id) + { + var filter = + Filter.And( + Filter.AnyEq(x => x.ReferencedIds, id), + Filter.Ne(x => x.IsDeleted, true), + Filter.Ne(x => x.Id, id)); + + var hasReferrerAsync = + await Collection.Find(filter).Only(x => x.Id) + .AnyAsync(); + + return hasReferrerAsync; + } + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs index b9d7da90d..898a7db95 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs @@ -33,10 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Apps private readonly IAppPlanBillingManager appPlansBillingManager; private readonly IUserResolver userResolver; - public AppDomainObject( + public AppDomainObject(IStore store, ISemanticLog log, InitialPatterns initialPatterns, - IStore store, - ISemanticLog log, IAppPlansProvider appPlansProvider, IAppPlanBillingManager appPlansBillingManager, IUserResolver userResolver) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs index 504b8efe9..587b08b4b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs @@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Guards; using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; @@ -21,23 +22,29 @@ using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; using Squidex.Infrastructure.Translations; +using IAssetTagService = Squidex.Domain.Apps.Core.Tags.ITagService; namespace Squidex.Domain.Apps.Entities.Assets { public class AssetDomainObject : LogSnapshotDomainObject { - private readonly ITagService tagService; + private readonly IContentRepository contentRepository; + private readonly IAssetTagService assetTags; private readonly IAssetQueryService assetQuery; - public AssetDomainObject(IStore store, ITagService tagService, IAssetQueryService assetQuery, ISemanticLog log) + public AssetDomainObject(IStore store, ISemanticLog log, + IAssetTagService assetTags, + IAssetQueryService assetQuery, + IContentRepository contentRepository) : base(store, log) { - Guard.NotNull(tagService, nameof(tagService)); + Guard.NotNull(assetTags, nameof(assetTags)); Guard.NotNull(assetQuery, nameof(assetQuery)); + Guard.NotNull(contentRepository, nameof(contentRepository)); - this.tagService = tagService; - + this.assetTags = assetTags; this.assetQuery = assetQuery; + this.contentRepository = contentRepository; } public override Task ExecuteAsync(IAggregateCommand command) @@ -91,7 +98,17 @@ namespace Squidex.Domain.Apps.Entities.Assets { GuardAsset.CanDelete(c); - await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags); + if (c.CheckReferrers) + { + var hasReferrer = await contentRepository.HasReferrersAsync(c.AssetId); + + if (hasReferrer) + { + throw new DomainException(T.Get("assets.referenced")); + } + } + + await assetTags.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags); Delete(c); }); @@ -107,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return null; } - var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); + var normalized = await assetTags.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); return new HashSet(normalized.Values); } @@ -116,10 +133,10 @@ namespace Squidex.Domain.Apps.Entities.Assets { var @event = SimpleMapper.Map(command, new AssetCreated { + MimeType = command.File.MimeType, FileName = command.File.FileName, FileSize = command.File.FileSize, FileVersion = 0, - MimeType = command.File.MimeType, Slug = command.File.FileName.ToAssetSlug() }); @@ -132,9 +149,9 @@ namespace Squidex.Domain.Apps.Entities.Assets { var @event = SimpleMapper.Map(command, new AssetUpdated { + MimeType = command.File.MimeType, FileVersion = Snapshot.FileVersion + 1, FileSize = command.File.FileSize, - MimeType = command.File.MimeType }); RaiseEvent(@event); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs index 33aeaff18..50d211f7d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs @@ -26,7 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Assets { private readonly IAssetQueryService assetQuery; - public AssetFolderDomainObject(IStore store, IAssetQueryService assetQuery, ISemanticLog log) + public AssetFolderDomainObject(ISemanticLog log, IStore store, + IAssetQueryService assetQuery) : base(store, log) { Guard.NotNull(assetQuery, nameof(assetQuery)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs index 4848be209..c06b7ac1d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs @@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands { public sealed class DeleteAsset : AssetCommand { + public bool CheckReferrers { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs index 28a0bd836..4d7c41f5f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs @@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class DeleteContent : ContentCommand { + public bool CheckReferrers { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index 9cd116038..98b125055 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Guards; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; @@ -28,15 +29,21 @@ namespace Squidex.Domain.Apps.Entities.Contents public class ContentDomainObject : LogSnapshotDomainObject { private readonly IContentWorkflow contentWorkflow; + private readonly IContentRepository contentRepository; private readonly ContentOperationContext context; - public ContentDomainObject(IStore store, IContentWorkflow contentWorkflow, ContentOperationContext context, ISemanticLog log) + public ContentDomainObject(IStore store, ISemanticLog log, + IContentWorkflow contentWorkflow, + IContentRepository contentRepository, + ContentOperationContext context) : base(store, log) { - Guard.NotNull(context, nameof(context)); + Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentWorkflow, nameof(contentWorkflow)); + Guard.NotNull(context, nameof(context)); this.contentWorkflow = contentWorkflow; + this.contentRepository = contentRepository; this.context = context; } @@ -204,6 +211,16 @@ namespace Squidex.Domain.Apps.Entities.Contents }); } + if (c.CheckReferrers) + { + var hasReferrer = await contentRepository.HasReferrersAsync(c.ContentId); + + if (hasReferrer) + { + throw new DomainException(T.Get("contents.referenced")); + } + } + Delete(c); }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 5ee6bfd46..5908d4174 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -16,9 +16,11 @@ using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Validation; #pragma warning disable IDE0016 // Use 'throw' expression @@ -43,7 +45,11 @@ namespace Squidex.Domain.Apps.Entities.Contents private ContentCommand command; private ValidationContext validationContext; - public ContentOperationContext(IAppProvider appProvider, IEnumerable factories, IScriptEngine scriptEngine, ISemanticLog log) + public ContentOperationContext( + IAppProvider appProvider, + IEnumerable factories, + IScriptEngine scriptEngine, + ISemanticLog log) { this.appProvider = appProvider; this.factories = factories; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 2339163e4..e0f8ff9b9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -28,6 +28,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task> QueryIdsAsync(Guid appId, HashSet ids, SearchScope scope); + Task HasReferrersAsync(Guid contentId); + Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope); Task ResetScheduledAsync(Guid contentId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs index 2be7ad683..807cadb1e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs @@ -27,14 +27,14 @@ namespace Squidex.Domain.Apps.Entities.Rules private readonly IAppProvider appProvider; private readonly IRuleEnqueuer ruleEnqueuer; - public RuleDomainObject(IStore store, ISemanticLog log, IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer) + public RuleDomainObject(IStore store, ISemanticLog log, + IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer) : base(store, log) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(ruleEnqueuer, nameof(ruleEnqueuer)); this.appProvider = appProvider; - this.ruleEnqueuer = ruleEnqueuer; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs index e5dbccb20..c1c4d484d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs @@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Entities public ClaimsPrincipal User { get; set; } + public bool FromRule { get; set; } + public long ExpectedVersion { get; set; } = EtagVersion.Auto; } } diff --git a/backend/src/Squidex.Domain.Apps.Events/SquidexEvent.cs b/backend/src/Squidex.Domain.Apps.Events/SquidexEvent.cs index 8d42986bf..93cc78a14 100644 --- a/backend/src/Squidex.Domain.Apps.Events/SquidexEvent.cs +++ b/backend/src/Squidex.Domain.Apps.Events/SquidexEvent.cs @@ -13,5 +13,7 @@ namespace Squidex.Domain.Apps.Events public abstract class SquidexEvent : IEvent { public RefToken Actor { get; set; } + + public bool FromRule { get; set; } } } diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs index fde1d0a57..8398c0771 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs @@ -15,10 +15,10 @@ namespace Squidex.Infrastructure.EventSourcing { public partial class MongoEventStore : MongoRepositoryBase, IEventStore { - private static readonly FieldDefinition TimestampField = Fields.Build(x => x.Timestamp); - private static readonly FieldDefinition EventsCountField = Fields.Build(x => x.EventsCount); - private static readonly FieldDefinition EventStreamOffsetField = Fields.Build(x => x.EventStreamOffset); - private static readonly FieldDefinition EventStreamField = Fields.Build(x => x.EventStream); + private static readonly FieldDefinition TimestampField = FieldBuilder.Build(x => x.Timestamp); + private static readonly FieldDefinition EventsCountField = FieldBuilder.Build(x => x.EventsCount); + private static readonly FieldDefinition EventStreamOffsetField = FieldBuilder.Build(x => x.EventStreamOffset); + private static readonly FieldDefinition EventStreamField = FieldBuilder.Build(x => x.EventStream); private readonly IEventNotifier notifier; public IMongoCollection RawCollection diff --git a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs index 8ca268545..9d40c7de2 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs @@ -23,7 +23,7 @@ namespace Squidex.Infrastructure.MongoDb protected static readonly ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true }; protected static readonly SortDefinitionBuilder Sort = Builders.Sort; protected static readonly UpdateDefinitionBuilder Update = Builders.Update; - protected static readonly FieldDefinitionBuilder Fields = FieldDefinitionBuilder.Instance; + protected static readonly FieldDefinitionBuilder FieldBuilder = FieldDefinitionBuilder.Instance; protected static readonly FilterDefinitionBuilder Filter = Builders.Filter; protected static readonly IndexKeysDefinitionBuilder Index = Builders.IndexKeys; protected static readonly ProjectionDefinitionBuilder Projection = Builders.Projection; diff --git a/backend/src/Squidex.Shared/Texts.it.resx b/backend/src/Squidex.Shared/Texts.it.resx index 08b32a3b5..3f0baa6ef 100644 --- a/backend/src/Squidex.Shared/Texts.it.resx +++ b/backend/src/Squidex.Shared/Texts.it.resx @@ -184,6 +184,9 @@ Hai raggiunto la dimensione massima consentito per le risorse. + + Assets is referenced by a content and cannot be deleted. + E' in esecuzione una altro processo di backup. @@ -493,6 +496,9 @@ {count} Collegamenti(s) + + Content is referenced by another content and cannot be deleted. + Il contenuto singleton non può essere aggiornato diff --git a/backend/src/Squidex.Shared/Texts.nl.resx b/backend/src/Squidex.Shared/Texts.nl.resx index 0b5bfc1f0..0c6056d44 100644 --- a/backend/src/Squidex.Shared/Texts.nl.resx +++ b/backend/src/Squidex.Shared/Texts.nl.resx @@ -184,6 +184,9 @@ Je hebt jouw maximale assetgrootte bereikt. + + Assets is referenced by a content and cannot be deleted. + Er wordt al een ander back-upproces uitgevoerd. @@ -493,6 +496,9 @@ {count} referentie (s) + + Content is referenced by another content and cannot be deleted. + Singleton-inhoud kan niet worden bijgewerkt. diff --git a/backend/src/Squidex.Shared/Texts.resx b/backend/src/Squidex.Shared/Texts.resx index 9755c2e6d..3d7c7e09f 100644 --- a/backend/src/Squidex.Shared/Texts.resx +++ b/backend/src/Squidex.Shared/Texts.resx @@ -184,6 +184,9 @@ You have reached your max asset size. + + Assets is referenced by a content and cannot be deleted. + Another backup process is already running. @@ -493,6 +496,9 @@ {count} Reference(s) + + Content is referenced by another content and cannot be deleted. + Singleton content cannot be updated. diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 7c6ffdc2c..33173ec61 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -284,6 +284,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// The name of the app. /// The id of the asset to delete. + /// True to check referrers of this asset. /// /// 204 => Asset deleted. /// 404 => Asset or app not found. @@ -292,9 +293,9 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("apps/{app}/assets/{id}/")] [ApiPermissionOrAnonymous(Permissions.AppAssetsDelete)] [ApiCosts(1)] - public async Task DeleteAsset(string app, Guid id) + public async Task DeleteAsset(string app, Guid id, [FromQuery] bool checkReferrers = false) { - await CommandBus.PublishAsync(new DeleteAsset { AssetId = id }); + await CommandBus.PublishAsync(new DeleteAsset { AssetId = id, CheckReferrers = checkReferrers }); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 0c40ae356..24cd6be7d 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -551,6 +551,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the app. /// The name of the schema. /// The id of the content item to delete. + /// True to check referrers of this content. /// /// 204 => Content deleted. /// 404 => Content, schema or app not found. @@ -562,9 +563,9 @@ namespace Squidex.Areas.Api.Controllers.Contents [Route("content/{app}/{name}/{id}/")] [ApiPermissionOrAnonymous(Permissions.AppContentsDelete)] [ApiCosts(1)] - public async Task DeleteContent(string app, string name, Guid id) + public async Task DeleteContent(string app, string name, Guid id, [FromQuery] bool checkReferrers = false) { - var command = new DeleteContent { ContentId = id }; + var command = new DeleteContent { ContentId = id, CheckReferrers = checkReferrers }; await CommandBus.PublishAsync(command); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs index 2ad6092c7..6afa79c12 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterCompareTests.cs @@ -705,6 +705,29 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules Assert.Equal("[1,2,3]", result?.Replace(" ", string.Empty)); } + [Theory] + [Expressions( + "$CONTENT_DATA", + "${CONTENT_DATA}", + "${JSON.stringify(event.data)}", + null + )] + public async Task Should_return_json_string_when_data(string script) + { + var @event = new EnrichedContentEvent + { + Data = + new NamedContentData() + .AddField("city", + new ContentFieldData() + .AddJsonValue(JsonValue.Object().Add("name", "Berlin"))) + }; + + var result = await sut.FormatAsync(script, @event); + + Assert.Equal("{\"city\":{\"iv\":{\"name\":\"Berlin\"}}}", result); + } + [Theory] [Expressions( null, diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs index 95ac00416..9bf2e961c 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs @@ -173,6 +173,24 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules .MustHaveHappened(); } + [Fact] + public async Task Should_not_create_job_if_event_created_by_rule() + { + var rule = ValidRule(); + + var @event = Envelope.Create(new ContentCreated { FromRule = true }); + + var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); + + Assert.Empty(jobs); + + A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) + .MustNotHaveHappened(); + + A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A>._)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_not_create_job_if_not_triggered_with_precheck() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs index 80e4c20d0..a5dd8c148 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { patternId2, new AppPattern("Numbers", "[0-9]*") } }; - sut = new AppDomainObject(initialPatterns, Store, A.Dummy(), appPlansProvider, appPlansBillingManager, userResolver); + sut = new AppDomainObject(Store, A.Dummy(), initialPatterns, appPlansProvider, appPlansBillingManager, userResolver); sut.Setup(Id); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index 054258307..bbc9cfc9c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -15,6 +15,7 @@ using Orleans; using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; @@ -30,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Assets private readonly IAssetFileStore assetFileStore = A.Fake(); private readonly IAssetMetadataSource assetMetadataSource = A.Fake(); private readonly IAssetQueryService assetQuery = A.Fake(); + private readonly IContentRepository contentRepository = A.Fake(); private readonly IContextProvider contextProvider = A.Fake(); private readonly IGrainFactory grainFactory = A.Fake(); private readonly IServiceProvider serviceProvider = A.Fake(); @@ -53,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { file = new NoopAssetFile(); - var assetDomainObject = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy()); + var assetDomainObject = new AssetDomainObject(Store, A.Dummy(), tagService, assetQuery, contentRepository); A.CallTo(() => serviceProvider.GetService(typeof(AssetDomainObject))) .Returns(assetDomainObject); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs index 0f8a5846b..bda178baa 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs @@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; @@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { public class AssetDomainObjectTests : HandlerTestBase { + private readonly IContentRepository contentRepository = A.Fake(); private readonly ITagService tagService = A.Fake(); private readonly IAssetQueryService assetQuery = A.Fake(); private readonly Guid parentId = Guid.NewGuid(); @@ -46,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Assets A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A>._, A>._)) .ReturnsLazily(x => Task.FromResult(x.GetArgument>(2)?.ToDictionary(x => x)!)); - sut = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy()); + sut = new AssetDomainObject(Store, A.Dummy(), tagService, assetQuery, contentRepository); sut.Setup(Id); } @@ -246,6 +248,32 @@ namespace Squidex.Domain.Apps.Entities.Assets ); } + [Fact] + public async Task Delete_should_throw_exception_if_referenced_by_other_item() + { + var command = new DeleteAsset { CheckReferrers = true }; + + await ExecuteCreateAsync(); + + A.CallTo(() => contentRepository.HasReferrersAsync(Id)) + .Returns(true); + + await Assert.ThrowsAsync(() => PublishAsync(command)); + } + + [Fact] + public async Task Delete_should_not_throw_exception_if_referenced_by_other_item_but_forced() + { + var command = new DeleteAsset(); + + await ExecuteCreateAsync(); + + A.CallTo(() => contentRepository.HasReferrersAsync(Id)) + .Returns(true); + + await PublishAsync(command); + } + private Task ExecuteCreateAsync() { return PublishAsync(new CreateAsset { File = file }); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs index fd681cf60..834e248d9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs @@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Assets A.CallTo(() => assetQuery.FindAssetFolderAsync(parentId)) .Returns(new List { A.Fake() }); - sut = new AssetFolderDomainObject(Store, assetQuery, A.Dummy()); + sut = new AssetFolderDomainObject(A.Dummy(), Store, assetQuery); sut.Setup(Id); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs index 05e72aef9..7c569611e 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs @@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; @@ -35,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IAppEntity app; private readonly IAppProvider appProvider = A.Fake(); private readonly IContentWorkflow contentWorkflow = A.Fake(x => x.Wrapping(new DefaultContentWorkflow())); + private readonly IContentRepository contentRepository = A.Fake(); private readonly ISchemaEntity schema; private readonly IScriptEngine scriptEngine = A.Fake(); @@ -105,9 +107,11 @@ namespace Squidex.Domain.Apps.Entities.Contents patched = patch.MergeInto(data); - var context = new ContentOperationContext(appProvider, Enumerable.Repeat(new DefaultValidatorsFactory(), 1), scriptEngine, A.Fake()); + var validators = Enumerable.Repeat(new DefaultValidatorsFactory(), 1); - sut = new ContentDomainObject(Store, contentWorkflow, context, A.Dummy()); + var context = new ContentOperationContext(appProvider, validators, scriptEngine, A.Fake()); + + sut = new ContentDomainObject(Store, A.Dummy(), contentWorkflow, contentRepository, context); sut.Setup(Id); } @@ -125,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = new CreateContent { Data = data }; - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -148,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = new CreateContent { Data = data, Publish = true }; - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -171,7 +175,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var command = new CreateContent { Data = invalidData }; - await Assert.ThrowsAsync(() => PublishAsync(CreateContentCommand(command))); + await Assert.ThrowsAsync(() => PublishAsync(command)); } [Fact] @@ -181,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -205,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecutePublishAsync(); await ExecuteCreateDraftAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -227,7 +231,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -244,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - await Assert.ThrowsAsync(() => PublishAsync(CreateContentCommand(command))); + await Assert.ThrowsAsync(() => PublishAsync(command)); } [Fact] @@ -254,7 +258,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -278,7 +282,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecutePublishAsync(); await ExecuteCreateDraftAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -300,7 +304,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -317,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -339,7 +343,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -362,7 +366,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); await ExecutePublishAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -386,7 +390,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecutePublishAsync(); await ExecuteCreateDraftAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -410,7 +414,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -441,7 +445,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentWorkflow.CanMoveToAsync(A._, Status.Draft, Status.Archived, User)) .Returns(true); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -469,7 +473,7 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentWorkflow.CanMoveToAsync(A._, Status.Draft, Status.Published, User)) .Returns(false); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -491,7 +495,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(new EntitySavedResult(1)); @@ -506,6 +510,32 @@ namespace Squidex.Domain.Apps.Entities.Contents .MustHaveHappened(); } + [Fact] + public async Task Delete_should_throw_exception_if_referenced_by_other_item() + { + var command = new DeleteContent { CheckReferrers = true }; + + await ExecuteCreateAsync(); + + A.CallTo(() => contentRepository.HasReferrersAsync(Id)) + .Returns(true); + + await Assert.ThrowsAsync(() => PublishAsync(command)); + } + + [Fact] + public async Task Delete_should_not_throw_exception_if_referenced_by_other_item_but_forced() + { + var command = new DeleteContent(); + + await ExecuteCreateAsync(); + + A.CallTo(() => contentRepository.HasReferrersAsync(Id)) + .Returns(true); + + await PublishAsync(command); + } + [Fact] public async Task CreateDraft_should_create_events_and_update_new_state() { @@ -514,7 +544,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecuteCreateAsync(); await ExecutePublishAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(sut.Snapshot); @@ -535,7 +565,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await ExecutePublishAsync(); await ExecuteCreateDraftAsync(); - var result = await PublishAsync(CreateContentCommand(command)); + var result = await PublishAsync(command); result.ShouldBeEquivalent(new EntitySavedResult(3)); @@ -549,22 +579,22 @@ namespace Squidex.Domain.Apps.Entities.Contents private Task ExecuteCreateAsync() { - return PublishAsync(CreateContentCommand(new CreateContent { Data = data })); + return PublishAsync(new CreateContent { Data = data }); } private Task ExecuteUpdateAsync() { - return PublishAsync(CreateContentCommand(new UpdateContent { Data = otherData })); + return PublishAsync(new UpdateContent { Data = otherData }); } private Task ExecuteCreateDraftAsync() { - return PublishAsync(CreateContentCommand(new CreateContentDraft())); + return PublishAsync(new CreateContentDraft()); } private Task ExecuteChangeStatusAsync(Status status, Instant? dueTime = null) { - return PublishAsync(CreateContentCommand(new ChangeContentStatus { Status = status, DueTime = dueTime })); + return PublishAsync(new ChangeContentStatus { Status = status, DueTime = dueTime }); } private Task ExecuteDeleteAsync() diff --git a/frontend/app/features/content/pages/content/content-history-page.component.html b/frontend/app/features/content/pages/content/content-history-page.component.html index 954456e14..9b009c7f1 100644 --- a/frontend/app/features/content/pages/content/content-history-page.component.html +++ b/frontend/app/features/content/pages/content/content-history-page.component.html @@ -36,7 +36,8 @@ + confirmText="i18n:contents.deleteVersionConfirmText" + confirmRememberKey="deleteDraft"> {{ 'contents.versionDelete' | sqxTranslate }} @@ -45,7 +46,8 @@ + confirmText="i18n:contents.deleteConfirmText" + confirmRememberKey="deleteContent"> {{ 'common.delete' | sqxTranslate }} @@ -85,7 +87,8 @@ + confirmText="i18n:contents.deleteConfirmText" + confirmRememberKey="deleteContent"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/content/pages/content/content-page.component.html b/frontend/app/features/content/pages/content/content-page.component.html index 8a36ce99f..98f4b25ef 100644 --- a/frontend/app/features/content/pages/content/content-page.component.html +++ b/frontend/app/features/content/pages/content/content-page.component.html @@ -36,7 +36,8 @@ + confirmText="i18n:contents.deleteConfirmText" + confirmRememberKey="deleteContent"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/content/pages/content/content-page.component.ts b/frontend/app/features/content/pages/content/content-page.component.ts index 67f279d76..7947d9bd8 100644 --- a/frontend/app/features/content/pages/content/content-page.component.ts +++ b/frontend/app/features/content/pages/content/content-page.component.ts @@ -11,7 +11,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ApiUrlConfig, AppLanguageDto, AppsState, AuthService, AutoSaveKey, AutoSaveService, CanComponentDeactivate, ContentDto, ContentsState, DialogService, EditContentForm, fadeAnimation, FieldForm, FieldSection, LanguagesState, ModalModel, ResourceOwner, RootFieldDto, SchemaDetailsDto, SchemasState, TempService, valueAll$, Version } from '@app/shared'; import { Observable, of } from 'rxjs'; -import { debounceTime, filter, onErrorResumeNext, tap } from 'rxjs/operators'; +import { debounceTime, filter, tap } from 'rxjs/operators'; @Component({ selector: 'sqx-content-page', @@ -192,10 +192,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD const content = this.content; if (content) { - this.contentsState.deleteMany([content]).pipe(onErrorResumeNext()) - .subscribe(() => { - this.back(); - }); + this.contentsState.deleteMany([content]); } } diff --git a/frontend/app/features/content/pages/contents/contents-page.component.html b/frontend/app/features/content/pages/contents/contents-page.component.html index b2d0f14c8..40f51bffb 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.html +++ b/frontend/app/features/content/pages/contents/contents-page.component.html @@ -55,7 +55,8 @@ diff --git a/frontend/app/features/content/pages/contents/contents-page.component.ts b/frontend/app/features/content/pages/contents/contents-page.component.ts index 256580a45..ee362526d 100644 --- a/frontend/app/features/content/pages/contents/contents-page.component.ts +++ b/frontend/app/features/content/pages/contents/contents-page.component.ts @@ -75,7 +75,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { this.own( this.route.params.pipe( - switchMap(x => this.schemasState.selectedSchema), distinctUntilChanged()) + switchMap(() => this.schemasState.selectedSchema), distinctUntilChanged()) .subscribe(schema => { this.resetSelection(); @@ -202,8 +202,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit { this.selectionCount++; for (const action in this.nextStatuses) { - if (!content.statusUpdates.find(x => x.status === action)) { - delete this.nextStatuses[action]; + if (this.nextStatuses.hasOwnProperty(action)) { + if (!content.statusUpdates.find(x => x.status === action)) { + delete this.nextStatuses[action]; + } } } diff --git a/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts b/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts index 9e02dd1e0..e54fee3d8 100644 --- a/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts +++ b/frontend/app/features/content/pages/sidebar/sidebar-page.component.ts @@ -18,9 +18,9 @@ import { combineLatest } from 'rxjs'; changeDetection: ChangeDetectionStrategy.OnPush }) export class SidebarPageComponent extends ResourceOwner implements AfterViewInit { - private isInitialized = false; - private context: any; + private readonly context: any; private content: any; + private isInitialized = false; @ViewChild('iframe', { static: false }) public iframe: ElementRef; diff --git a/frontend/app/features/content/shared/due-time-selector.component.ts b/frontend/app/features/content/shared/due-time-selector.component.ts index 0c1abfa79..0aaeac08a 100644 --- a/frontend/app/features/content/shared/due-time-selector.component.ts +++ b/frontend/app/features/content/shared/due-time-selector.component.ts @@ -17,7 +17,7 @@ const OPTION_IMMEDIATELY = 'Immediately'; templateUrl: './due-time-selector.component.html' }) export class DueTimeSelectorComponent { - private disabled: boolean; + private readonly disabled: boolean; private dueTimeResult: Subject; public dueTimeDialog = new DialogModel(); diff --git a/frontend/app/features/content/shared/list/content.component.html b/frontend/app/features/content/shared/list/content.component.html index cdef0e7cd..816bb890b 100644 --- a/frontend/app/features/content/shared/list/content.component.html +++ b/frontend/app/features/content/shared/list/content.component.html @@ -41,7 +41,8 @@ + confirmText="i18n:contents.deleteConfirmText" + confirmRememberKey="deleteContent"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/content/shared/references/reference-item.component.html b/frontend/app/features/content/shared/references/reference-item.component.html index 83a1667e8..03e68b105 100644 --- a/frontend/app/features/content/shared/references/reference-item.component.html +++ b/frontend/app/features/content/shared/references/reference-item.component.html @@ -29,7 +29,8 @@ diff --git a/frontend/app/features/dashboard/pages/dashboard-config.component.html b/frontend/app/features/dashboard/pages/dashboard-config.component.html index 98f186417..d3784d209 100644 --- a/frontend/app/features/dashboard/pages/dashboard-config.component.html +++ b/frontend/app/features/dashboard/pages/dashboard-config.component.html @@ -26,7 +26,8 @@ + confirmText="i18n:dashboard.resetConfigConfirmText" + confirmRememberKey="resetConfig"> {{ 'common.reset' | sqxTranslate }} 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 038c5d184..a3fca239d 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,8 +13,8 @@
- -
diff --git a/frontend/app/features/rules/pages/rules/rule.component.html b/frontend/app/features/rules/pages/rules/rule.component.html index af003078c..c8ce33832 100644 --- a/frontend/app/features/rules/pages/rules/rule.component.html +++ b/frontend/app/features/rules/pages/rules/rule.component.html @@ -20,14 +20,16 @@ + confirmText="i18n:rules.runRuleConfirmText" + confirmRememberKey="runRule"> {{ 'rules.run' | sqxTranslate }} + confirmText="i18n:rules.deleteConfirmText" + confirmRememberKey="deleteContent"> {{ 'common.delete' | sqxTranslate }} @@ -58,7 +60,8 @@
diff --git a/frontend/app/features/schemas/pages/schema/fields/field.component.html b/frontend/app/features/schemas/pages/schema/fields/field.component.html index 12c15e90e..cddad0626 100644 --- a/frontend/app/features/schemas/pages/schema/fields/field.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/field.component.html @@ -69,7 +69,8 @@ + confirmText="i18n:schemas.field.lockConfirmText" + confirmRememberKey="lockField"> {{ 'schemas.field.lock' | sqxTranslate }} @@ -80,7 +81,8 @@ + confirmText="i18n:schemas.field.deleteConfirmText" + confirmRememberKey="deleteField"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html b/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html index f5924e9e0..8b832f51f 100644 --- a/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html +++ b/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html @@ -29,7 +29,8 @@ diff --git a/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts b/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts index 102b2b80d..0576086dd 100644 --- a/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts +++ b/frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts @@ -48,7 +48,7 @@ export class SchemaPreviewUrlsFormComponent implements OnChanges { if (value) { this.schemasState.configurePreviewUrls(this.schema, value) - .subscribe(update => { + .subscribe(() => { this.editForm.submitCompleted({ noReset: true }); }, error => { this.editForm.submitFailed(error); diff --git a/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html b/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html index 463d50bc6..14434ba82 100644 --- a/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html +++ b/frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html @@ -39,7 +39,8 @@ diff --git a/frontend/app/features/schemas/pages/schema/schema-page.component.html b/frontend/app/features/schemas/pages/schema/schema-page.component.html index 295baacf5..af9467156 100644 --- a/frontend/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/app/features/schemas/pages/schema/schema-page.component.html @@ -40,7 +40,8 @@ + confirmText="i18n:schemas.deleteConfirmText" + confirmRememberKey="deleteSchema"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/features/settings/pages/backups/backup.component.html b/frontend/app/features/settings/pages/backups/backup.component.html index c4b8cdfe8..5068bc9a7 100644 --- a/frontend/app/features/settings/pages/backups/backup.component.html +++ b/frontend/app/features/settings/pages/backups/backup.component.html @@ -40,7 +40,8 @@ diff --git a/frontend/app/features/settings/pages/clients/client.component.html b/frontend/app/features/settings/pages/clients/client.component.html index 4b3eecec9..1c7a8c04c 100644 --- a/frontend/app/features/settings/pages/clients/client.component.html +++ b/frontend/app/features/settings/pages/clients/client.component.html @@ -12,7 +12,8 @@ diff --git a/frontend/app/features/settings/pages/contributors/contributor.component.html b/frontend/app/features/settings/pages/contributors/contributor.component.html index f047c4879..c26bfde61 100644 --- a/frontend/app/features/settings/pages/contributors/contributor.component.html +++ b/frontend/app/features/settings/pages/contributors/contributor.component.html @@ -14,7 +14,8 @@ diff --git a/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts b/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts index 0cb034bd6..4b8d4f678 100644 --- a/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts +++ b/frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts @@ -8,7 +8,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { ContributorsState, ErrorDto, ImportContributorsForm, RoleDto } from '@app/shared'; -import { empty, of } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; import { catchError, mergeMap, tap } from 'rxjs/operators'; type ImportStatus = { @@ -79,7 +79,7 @@ export class ImportContributorsDialogComponent { status.result = 'Failed'; } - return empty(); + return EMPTY; }) ), 1) ).subscribe(); diff --git a/frontend/app/features/settings/pages/languages/language.component.html b/frontend/app/features/settings/pages/languages/language.component.html index 219b13ce4..d23b6feeb 100644 --- a/frontend/app/features/settings/pages/languages/language.component.html +++ b/frontend/app/features/settings/pages/languages/language.component.html @@ -16,7 +16,8 @@ diff --git a/frontend/app/features/settings/pages/patterns/pattern.component.html b/frontend/app/features/settings/pages/patterns/pattern.component.html index 6944ec286..85186665d 100644 --- a/frontend/app/features/settings/pages/patterns/pattern.component.html +++ b/frontend/app/features/settings/pages/patterns/pattern.component.html @@ -27,7 +27,8 @@ diff --git a/frontend/app/features/settings/pages/plans/plan.component.html b/frontend/app/features/settings/pages/plans/plan.component.html index 388c0598c..44aa3c0dc 100644 --- a/frontend/app/features/settings/pages/plans/plan.component.html +++ b/frontend/app/features/settings/pages/plans/plan.component.html @@ -27,6 +27,7 @@ diff --git a/frontend/app/features/settings/pages/workflows/workflow.component.html b/frontend/app/features/settings/pages/workflows/workflow.component.html index de30cf102..0058d3885 100644 --- a/frontend/app/features/settings/pages/workflows/workflow.component.html +++ b/frontend/app/features/settings/pages/workflows/workflow.component.html @@ -5,7 +5,11 @@ {{workflow.displayName}}
- +
@@ -17,7 +21,8 @@
@@ -62,7 +67,10 @@
- + @@ -71,7 +79,15 @@
- + diff --git a/frontend/app/shared/components/assets/asset-folder.component.html b/frontend/app/shared/components/assets/asset-folder.component.html index 079c6bf76..a6d0864e7 100644 --- a/frontend/app/shared/components/assets/asset-folder.component.html +++ b/frontend/app/shared/components/assets/asset-folder.component.html @@ -26,7 +26,8 @@ + confirmText="i18n:assets.deleteFolderConfirmText" + confirmRememberKey="deleteAssetFolder"> {{ 'common.delete' | sqxTranslate }} diff --git a/frontend/app/shared/components/assets/asset.component.html b/frontend/app/shared/components/assets/asset.component.html index 529a4f2c6..12a59f209 100644 --- a/frontend/app/shared/components/assets/asset.component.html +++ b/frontend/app/shared/components/assets/asset.component.html @@ -33,14 +33,16 @@ + confirmText="i18n:assets.deleteConfirmText" + confirmRememberKey="deleteAsset"> + confirmText="i18n:assets.removeConfirmText" + confirmRememberKey="removeAsset"> @@ -134,14 +136,16 @@ diff --git a/frontend/app/shared/components/comments/comment.component.html b/frontend/app/shared/components/comments/comment.component.html index 403732ad9..16d95918d 100644 --- a/frontend/app/shared/components/comments/comment.component.html +++ b/frontend/app/shared/components/comments/comment.component.html @@ -49,6 +49,7 @@ (sqxConfirmClick)="delete()" confirmTitle="i18n:comments.deleteConfirmTitle" confirmText="i18n:comments.deleteConfirmText" + confirmRememberKey="deleteComment" [confirmRequired]="confirmDelete"> diff --git a/frontend/app/shared/components/forms/references-checkboxes.component.ts b/frontend/app/shared/components/forms/references-checkboxes.component.ts index 40c0e63ec..ade5e5137 100644 --- a/frontend/app/shared/components/forms/references-checkboxes.component.ts +++ b/frontend/app/shared/components/forms/references-checkboxes.component.ts @@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush }) export class ReferencesCheckboxesComponent extends StatefulControlComponent> implements OnChanges { - private itemCount: number; + private readonly itemCount: number; private contentItems: ReadonlyArray | null = null; @Input() diff --git a/frontend/app/shared/components/forms/references-dropdown.component.ts b/frontend/app/shared/components/forms/references-dropdown.component.ts index e7e8aa2c7..4d457c460 100644 --- a/frontend/app/shared/components/forms/references-dropdown.component.ts +++ b/frontend/app/shared/components/forms/references-dropdown.component.ts @@ -38,9 +38,9 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush }) export class ReferencesDropdownComponent extends StatefulControlComponent | string> implements OnChanges { + private readonly itemCount: number; private languageField: LanguageDto; private selectedId: string | undefined; - private itemCount: number; @Input() public schemaId: string; diff --git a/frontend/app/shared/components/forms/references-tags.component.ts b/frontend/app/shared/components/forms/references-tags.component.ts index a79297305..dca5d2909 100644 --- a/frontend/app/shared/components/forms/references-tags.component.ts +++ b/frontend/app/shared/components/forms/references-tags.component.ts @@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush }) export class ReferencesTagsComponent extends StatefulControlComponent> implements OnChanges { - private itemCount: number; + private readonly itemCount: number; private contentItems: ReadonlyArray | null = null; @Input() diff --git a/frontend/app/shared/guards/load-apps.guard.ts b/frontend/app/shared/guards/load-apps.guard.ts index ead7f12bd..7712803c3 100644 --- a/frontend/app/shared/guards/load-apps.guard.ts +++ b/frontend/app/shared/guards/load-apps.guard.ts @@ -19,6 +19,6 @@ export class LoadAppsGuard implements CanActivate { } public canActivate(): Observable { - return this.appsState.load().pipe(map(a => true)); + return this.appsState.load().pipe(map(_ => true)); } } \ No newline at end of file diff --git a/frontend/app/shared/guards/load-languages.guard.ts b/frontend/app/shared/guards/load-languages.guard.ts index 45ce8f593..c6c18e2be 100644 --- a/frontend/app/shared/guards/load-languages.guard.ts +++ b/frontend/app/shared/guards/load-languages.guard.ts @@ -19,6 +19,6 @@ export class LoadLanguagesGuard implements CanActivate { } public canActivate(): Observable { - return this.languagesState.load().pipe(map(a => true)); + return this.languagesState.load().pipe(map(_ => true)); } } \ No newline at end of file diff --git a/frontend/app/shared/interceptors/auth.interceptor.ts b/frontend/app/shared/interceptors/auth.interceptor.ts index 000d6cdce..50009ec90 100644 --- a/frontend/app/shared/interceptors/auth.interceptor.ts +++ b/frontend/app/shared/interceptors/auth.interceptor.ts @@ -9,13 +9,13 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { ApiUrlConfig, ErrorDto } from '@app/framework'; -import { empty, Observable, throwError } from 'rxjs'; +import { EMPTY, Observable, throwError } from 'rxjs'; import { catchError, switchMap, take } from 'rxjs/operators'; import { AuthService, Profile } from './../services/auth.service'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - private baseUrl: string; + private readonly baseUrl: string; constructor(apiUrlConfig: ApiUrlConfig, private readonly authService: AuthService, @@ -52,7 +52,7 @@ export class AuthInterceptor implements HttpInterceptor { catchError(() => { this.authService.logoutRedirect(); - return empty(); + return EMPTY; }), switchMap(u => this.makeRequest(req, next, u))); } else if (error.status === 401 || error.status === 403) { @@ -63,7 +63,7 @@ export class AuthInterceptor implements HttpInterceptor { this.router.navigate(['/forbidden'], { replaceUrl: true }); } - return empty(); + return EMPTY; } else { return throwError(new ErrorDto(403, 'i18n:common.errorNoPermission')); } diff --git a/frontend/app/shared/services/assets.service.spec.ts b/frontend/app/shared/services/assets.service.spec.ts index edd344773..041b9beb6 100644 --- a/frontend/app/shared/services/assets.service.spec.ts +++ b/frontend/app/shared/services/assets.service.spec.ts @@ -376,9 +376,9 @@ describe('AssetsService', () => { } }; - assetsService.deleteAssetItem('my-app', resource, version).subscribe(); + assetsService.deleteAssetItem('my-app', resource, true, version).subscribe(); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123?checkReferrers=true'); expect(req.request.method).toEqual('DELETE'); expect(req.request.headers.get('If-Match')).toEqual(version.value); diff --git a/frontend/app/shared/services/assets.service.ts b/frontend/app/shared/services/assets.service.ts index 882f48aa1..186282208 100644 --- a/frontend/app/shared/services/assets.service.ts +++ b/frontend/app/shared/services/assets.service.ts @@ -394,10 +394,10 @@ export class AssetsService { pretifyError('i18n:assets.moveFailed')); } - public deleteAssetItem(appName: string, asset: Resource, version: Version): Observable> { + public deleteAssetItem(appName: string, asset: Resource, checkReferrers: boolean, version: Version): Observable> { const link = asset._links['delete']; - const url = this.apiUrl.buildUrl(link.href); + const url = this.apiUrl.buildUrl(link.href) + `?checkReferrers=${checkReferrers}`; return HTTP.requestVersioned(this.http, link.method, url, version).pipe( tap(() => { diff --git a/frontend/app/shared/services/contents.service.spec.ts b/frontend/app/shared/services/contents.service.spec.ts index 9beafccec..199271193 100644 --- a/frontend/app/shared/services/contents.service.spec.ts +++ b/frontend/app/shared/services/contents.service.spec.ts @@ -389,9 +389,9 @@ describe('ContentsService', () => { } }; - contentsService.deleteContent('my-app', resource, version).subscribe(); + contentsService.deleteContent('my-app', resource, true, version).subscribe(); - const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1'); + const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?checkReferrers=true'); expect(req.request.method).toEqual('DELETE'); expect(req.request.headers.get('If-Match')).toEqual(version.value); diff --git a/frontend/app/shared/services/contents.service.ts b/frontend/app/shared/services/contents.service.ts index 55e49386e..c5db772f2 100644 --- a/frontend/app/shared/services/contents.service.ts +++ b/frontend/app/shared/services/contents.service.ts @@ -316,10 +316,10 @@ export class ContentsService { pretifyError(`Failed to ${status} content. Please reload.`)); } - public deleteContent(appName: string, resource: Resource, version: Version): Observable> { + public deleteContent(appName: string, resource: Resource, checkReferrers: boolean, version: Version): Observable> { const link = resource._links['delete']; - const url = this.apiUrl.buildUrl(link.href); + const url = this.apiUrl.buildUrl(link.href) + `?checkReferrers=${checkReferrers}`; return HTTP.requestVersioned(this.http, link.method, url, version).pipe( tap(() => { diff --git a/frontend/app/shared/services/users-provider.service.ts b/frontend/app/shared/services/users-provider.service.ts index 5901bc07d..87f29e36d 100644 --- a/frontend/app/shared/services/users-provider.service.ts +++ b/frontend/app/shared/services/users-provider.service.ts @@ -27,7 +27,7 @@ export class UsersProviderService { if (!result) { const request = this.usersService.getUser(id).pipe( - catchError(error => { + catchError(() => { return of(new UserDto('Unknown', 'Unknown')); }), publishLast()); diff --git a/frontend/app/shared/state/assets.state.spec.ts b/frontend/app/shared/state/assets.state.spec.ts index 4571ade2a..d2e75f056 100644 --- a/frontend/app/shared/state/assets.state.spec.ts +++ b/frontend/app/shared/state/assets.state.spec.ts @@ -1,3 +1,4 @@ +import { ErrorDto } from '@app/framework'; /* * Squidex Headless CMS * @@ -331,7 +332,7 @@ describe('AssetsState', () => { }); it('should remove asset from snapshot when deleted', () => { - assetsService.setup(x => x.deleteAssetItem(app, asset1, asset1.version)) + assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version)) .returns(() => of(versioned(newVersion))); assetsState.deleteAsset(asset1).subscribe(); @@ -341,8 +342,37 @@ describe('AssetsState', () => { expect(assetsState.snapshot.tagsAvailable).toEqual({ shared: 1, tag2: 1 }); }); + it('should remove asset from snapshot when when referenced and not confirmed', () => { + assetsService.setup(x => x.deleteAssetItem(app, asset1, false, asset1.version)) + .returns(() => throwError(new ErrorDto(404, 'Referenced'))); + + assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version)) + .returns(() => of(versioned(newVersion))); + + dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString())) + .returns(() => of(true)); + + assetsState.deleteAsset(asset1).subscribe(); + + expect(assetsState.snapshot.assets.length).toBe(1); + expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199); + expect(assetsState.snapshot.tagsAvailable).toEqual({ shared: 1, tag2: 1 }); + }); + + it('should not remove asset when referenced and not confirmed', () => { + assetsService.setup(x => x.deleteAssetItem(app, asset1, true, asset1.version)) + .returns(() => throwError(new ErrorDto(404, 'Referenced'))); + + dialogs.setup(x => x.confirm(It.isAnyString(), It.isAnyString(), It.isAnyString())) + .returns(() => of(false)); + + assetsState.deleteAsset(asset1).pipe(onErrorResumeNext()).subscribe(); + + expect(assetsState.snapshot.assets.length).toBe(2); + }); + it('should remove asset folder from snapshot when deleted', () => { - assetsService.setup(x => x.deleteAssetItem(app, assetFolder1, assetFolder1.version)) + assetsService.setup(x => x.deleteAssetItem(app, assetFolder1, false, assetFolder1.version)) .returns(() => of(versioned(newVersion))); assetsState.deleteAssetFolder(assetFolder1).subscribe(); diff --git a/frontend/app/shared/state/assets.state.ts b/frontend/app/shared/state/assets.state.ts index f52f93156..1db02e762 100644 --- a/frontend/app/shared/state/assets.state.ts +++ b/frontend/app/shared/state/assets.state.ts @@ -6,9 +6,9 @@ */ import { Injectable } from '@angular/core'; -import { compareStrings, DialogService, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework'; -import { empty, forkJoin, Observable, of, throwError } from 'rxjs'; -import { catchError, finalize, tap } from 'rxjs/operators'; +import { compareStrings, DialogService, ErrorDto, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework'; +import { EMPTY, forkJoin, Observable, of, throwError } from 'rxjs'; +import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service'; import { AppsState } from './apps.state'; import { Query, QueryFullTextSynchronizer } from './query'; @@ -274,7 +274,7 @@ export class AssetsState extends State { public moveAsset(asset: AssetDto, parentId?: string) { if (asset.parentId === parentId) { - return empty(); + return EMPTY; } this.next(s => { @@ -298,7 +298,7 @@ export class AssetsState extends State { public moveAssetFolder(assetFolder: AssetFolderDto, parentId?: string) { if (assetFolder.id === parentId || assetFolder.parentId === parentId) { - return empty(); + return EMPTY; } this.next(s => { @@ -320,8 +320,27 @@ export class AssetsState extends State { shareSubscribed(this.dialogs)); } - public deleteAsset(asset: AssetDto): Observable { - return this.assetsService.deleteAssetItem(this.appName, asset, asset.version).pipe( + public deleteAsset(asset: AssetDto) { + return this.assetsService.deleteAssetItem(this.appName, asset, true, asset.version).pipe( + catchError((error: ErrorDto) => { + if (error.statusCode === 400) { + return this.dialogs.confirm( + 'i18n:assets.deleteReferrerConfirmTitle', + 'i18n:assets.deleteReferrerConfirmText', + 'deleteReferencedAsset' + ).pipe( + switchMap(confirmed => { + if (confirmed) { + return this.assetsService.deleteAssetItem(this.appName, asset, false, asset.version); + } else { + return EMPTY; + } + }) + ); + } else { + return throwError(error); + } + }), tap(() => { this.next(s => { const assets = s.assets.filter(x => x.id !== asset.id); @@ -336,7 +355,7 @@ export class AssetsState extends State { } public deleteAssetFolder(assetFolder: AssetFolderDto): Observable { - return this.assetsService.deleteAssetItem(this.appName, assetFolder, assetFolder.version).pipe( + return this.assetsService.deleteAssetItem(this.appName, assetFolder, false, assetFolder.version).pipe( tap(() => { this.next(s => { const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id); diff --git a/frontend/app/shared/state/contents.forms-helpers.ts b/frontend/app/shared/state/contents.forms-helpers.ts index c8c2dfa26..cc09196d9 100644 --- a/frontend/app/shared/state/contents.forms-helpers.ts +++ b/frontend/app/shared/state/contents.forms-helpers.ts @@ -71,7 +71,7 @@ export class PartitionConfig { } export class CompiledRule { - private function: Function; + private readonly function: Function; public get field() { return this.rule.field; diff --git a/frontend/app/shared/state/contents.forms.visitors.ts b/frontend/app/shared/state/contents.forms.visitors.ts index f39f8e452..e4f9e8243 100644 --- a/frontend/app/shared/state/contents.forms.visitors.ts +++ b/frontend/app/shared/state/contents.forms.visitors.ts @@ -39,7 +39,7 @@ export function getContentValue(content: ContentDto, language: LanguageDto, fiel fieldValue = reference[fieldInvariant]; } - let value: string | undefined = undefined; + let value: string | undefined; if (Types.isObject(fieldValue)) { value = fieldValue[language.iso2Code]; diff --git a/frontend/app/shared/state/contents.state.ts b/frontend/app/shared/state/contents.state.ts index be7117ee1..2f34bdde3 100644 --- a/frontend/app/shared/state/contents.state.ts +++ b/frontend/app/shared/state/contents.state.ts @@ -7,8 +7,8 @@ import { Injectable } from '@angular/core'; import { DialogService, ErrorDto, Pager, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework'; -import { empty, forkJoin, Observable, of } from 'rxjs'; -import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; +import { EMPTY, forkJoin, Observable, of } from 'rxjs'; +import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators'; import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service'; import { SchemaDto } from './../services/schemas.service'; import { AppsState } from './apps.state'; @@ -16,6 +16,8 @@ import { SavedQuery } from './queries'; import { Query, QuerySynchronizer } from './query'; import { SchemasState } from './schemas.state'; +type Updated = { content: ContentDto, error?: ErrorDto }; + interface Snapshot { // The current comments. contents: ReadonlyArray; @@ -141,7 +143,7 @@ export abstract class ContentsStateBase extends State { public loadIfNotLoaded(): Observable { if (this.snapshot.isLoaded) { - return empty(); + return EMPTY; } return this.loadInternal(false); @@ -153,7 +155,7 @@ export abstract class ContentsStateBase extends State { private loadInternalCore(isReload: boolean) { if (!this.appName || !this.schemaName) { - return empty(); + return EMPTY; } this.next({ isLoading: true }); @@ -221,39 +223,82 @@ export abstract class ContentsStateBase extends State { shareSubscribed(this.dialogs, {silent: true})); } - public changeManyStatus(contents: ReadonlyArray, status: string, dueTime: string | null): Observable { - return forkJoin( - contents.map(c => - this.contentsService.putStatus(this.appName, c, status, dueTime, c.version).pipe( - catchError(error => of(error))))).pipe( + public changeManyStatus(contentsToChange: ReadonlyArray, status: string, dueTime: string | null): Observable { + return this.updateManyStatus(contentsToChange, status, dueTime).pipe( tap(results => { - const error = results.find(x => x instanceof ErrorDto); + const errors = results.filter(x => !!x.error); + + if (errors.length > 0) { + const errror = errors[0].error!; - if (error) { - this.dialogs.notifyError(error); + if (errors.length === contentsToChange.length) { + throw errror; + } else { + this.dialogs.notifyError(errror); + } } - return of(error); + this.next(s => { + let contents = s.contents; + + for (const updated of results.filter(x => !x.error).map(x => x.content)) { + contents = contents.replaceBy('id', updated); + } + + return { ...s, contents }; + }); }), - switchMap(() => this.loadInternalCore(false)), - shareSubscribed(this.dialogs, { silent: true })); + shareSubscribed(this.dialogs)); } - public deleteMany(contents: ReadonlyArray): Observable { - return forkJoin( - contents.map(c => - this.contentsService.deleteContent(this.appName, c, c.version).pipe( - catchError(error => of(error))))).pipe( + public deleteMany(contentsToDelete: ReadonlyArray) { + return this.deleteManyCore(contentsToDelete, true).pipe( + switchMap(results => { + const referenced = results.filter(x => x.error?.statusCode === 400).map(x => x.content); + + if (referenced.length > 0) { + return this.dialogs.confirm( + 'i18n:contents.deleteReferrerConfirmTitle', + 'i18n:contents.deleteReferrerConfirmText', + 'deleteReferencedAsset' + ).pipe( + switchMap(confirmed => { + if (confirmed) { + return this.deleteManyCore(referenced, false); + } else { + return of([]); + } + }) + ); + } else { + return of(results); + } + }), tap(results => { - const error = results.find(x => x instanceof ErrorDto); + const errors = results.filter(x => !!x.error); + + if (errors.length > 0) { + const errror = errors[0].error!; - if (error) { - this.dialogs.notifyError(error); + if (errors.length === contentsToDelete.length) { + throw errror; + } else { + this.dialogs.notifyError(errror); + } } - return of(error); + this.next(s => { + let contents = s.contents; + let contentsPager = s.contentsPager; + + for (const content of results.filter(x => !x.error).map(x => x.content)) { + contents = contents.filter(x => x.id !== content.id); + contentsPager = contentsPager.decrementCount(); + } + + return { ...s, contents, contentsPager }; + }); }), - switchMap(() => this.loadInternal(false)), shareSubscribed(this.dialogs, { silent: true })); } @@ -329,6 +374,26 @@ export abstract class ContentsStateBase extends State { } } + private deleteManyCore(contents: ReadonlyArray, checkReferrers: boolean): Observable> { + return forkJoin( + contents.map(c => this.deleteCore(c, checkReferrers))); + } + + private updateManyStatus(contents: ReadonlyArray, status: string, dueTime: string | null): Observable> { + return forkJoin( + contents.map(c => this.updateStatus(c, status, dueTime))); + } + + private deleteCore(content: ContentDto, checkReferrers: boolean): Observable { + return this.contentsService.deleteContent(this.appName, content, checkReferrers, content.version).pipe( + map(() => ({ content })), catchError(error => of({ content, error }))); + } + + private updateStatus(content: ContentDto, status: string, dueTime: string | null): Observable { + return this.contentsService.putStatus(this.appName, content, status, dueTime, content.version).pipe( + map(x => ({ content: x })), catchError(error => of({ content, error }))); + } + public abstract get schemaId(): string; public abstract get schemaName(): string; diff --git a/frontend/app/shared/state/contributors.state.spec.ts b/frontend/app/shared/state/contributors.state.spec.ts index 5a6847c40..5953a99c5 100644 --- a/frontend/app/shared/state/contributors.state.spec.ts +++ b/frontend/app/shared/state/contributors.state.spec.ts @@ -7,7 +7,7 @@ import { ErrorDto } from '@app/framework'; import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, Pager, versioned } from '@app/shared/internal'; -import { empty, of, throwError } from 'rxjs'; +import { EMPTY, of, throwError } from 'rxjs'; import { catchError, onErrorResumeNext } from 'rxjs/operators'; import { IMock, It, Mock, Times } from 'typemoq'; import { createContributors } from './../services/contributors.service.spec'; @@ -164,7 +164,7 @@ describe('ContributorsState', () => { catchError(err => { error = err; - return empty(); + return EMPTY; }) ).subscribe(); @@ -183,7 +183,7 @@ describe('ContributorsState', () => { catchError(err => { error = err; - return empty(); + return EMPTY; }) ).subscribe(); diff --git a/frontend/app/shared/state/rule-events.state.ts b/frontend/app/shared/state/rule-events.state.ts index d849711d2..9c714087b 100644 --- a/frontend/app/shared/state/rule-events.state.ts +++ b/frontend/app/shared/state/rule-events.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { DialogService, Pager, Router2State, shareSubscribed, State } from '@app/framework'; -import { empty, Observable } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { finalize, tap } from 'rxjs/operators'; import { RuleEventDto, RulesService } from './../services/rules.service'; import { AppsState } from './apps.state'; @@ -122,7 +122,7 @@ export class RuleEventsState extends State { public filterByRule(ruleId?: string) { if (ruleId === this.snapshot.ruleId) { - return empty(); + return EMPTY; } this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.reset(), ruleId })); diff --git a/frontend/app/shared/state/schemas.state.ts b/frontend/app/shared/state/schemas.state.ts index 4ba4a8327..a5da0ba4b 100644 --- a/frontend/app/shared/state/schemas.state.ts +++ b/frontend/app/shared/state/schemas.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { compareStrings, defined, DialogService, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework'; -import { empty, Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { catchError, finalize, tap } from 'rxjs/operators'; import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service'; import { AppsState } from './apps.state'; @@ -125,7 +125,7 @@ export class SchemasState extends State { public loadIfNotLoaded(): Observable { if (this.snapshot.isLoaded) { - return empty(); + return EMPTY; } return this.loadInternal(false); diff --git a/frontend/app/shell/pages/home/home-page.component.ts b/frontend/app/shell/pages/home/home-page.component.ts index a277baac0..42da3b5f5 100644 --- a/frontend/app/shell/pages/home/home-page.component.ts +++ b/frontend/app/shell/pages/home/home-page.component.ts @@ -30,7 +30,7 @@ export class HomePageComponent { this.authService.loginPopup() .subscribe(() => { this.router.navigate(['/app']); - }, error => { + }, _ => { this.showLoginError = true; }); }