Browse Source

Check references when deleting. (#583)

* Check references when deleting.

* Tests fixed

* Performance optimizations.
pull/585/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
e3d5029922
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      backend/i18n/frontend_en.json
  2. 5
      backend/i18n/frontend_it.json
  3. 5
      backend/i18n/frontend_nl.json
  4. 2
      backend/i18n/source/backend_en.json
  5. 5
      backend/i18n/source/frontend_en.json
  6. 32
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Fields.cs
  7. 10
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs
  8. 12
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  9. 32
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Fields.cs
  10. 11
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  11. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs
  13. 17
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs
  14. 45
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryReferrersAsync.cs
  15. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs
  16. 35
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs
  17. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs
  18. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs
  19. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/DeleteContent.cs
  20. 21
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs
  21. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  22. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  23. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs
  24. 8
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs
  25. 2
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs
  26. 6
      backend/src/Squidex.Shared/Texts.it.resx
  27. 6
      backend/src/Squidex.Shared/Texts.nl.resx
  28. 6
      backend/src/Squidex.Shared/Texts.resx
  29. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  30. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  31. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs
  32. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  33. 30
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs
  34. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetFolderDomainObjectTests.cs
  35. 82
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs
  36. 9
      frontend/app/features/content/pages/content/content-history-page.component.html
  37. 3
      frontend/app/features/content/pages/content/content-page.component.html
  38. 7
      frontend/app/features/content/pages/content/content-page.component.ts
  39. 3
      frontend/app/features/content/pages/contents/contents-page.component.html
  40. 8
      frontend/app/features/content/pages/contents/contents-page.component.ts
  41. 4
      frontend/app/features/content/pages/sidebar/sidebar-page.component.ts
  42. 2
      frontend/app/features/content/shared/due-time-selector.component.ts
  43. 3
      frontend/app/features/content/shared/list/content.component.html
  44. 3
      frontend/app/features/content/shared/references/reference-item.component.html
  45. 3
      frontend/app/features/dashboard/pages/dashboard-config.component.html
  46. 9
      frontend/app/features/rules/pages/rules/rule.component.html
  47. 6
      frontend/app/features/schemas/pages/schema/fields/field.component.html
  48. 3
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html
  49. 2
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts
  50. 3
      frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html
  51. 3
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  52. 3
      frontend/app/features/settings/pages/backups/backup.component.html
  53. 3
      frontend/app/features/settings/pages/clients/client.component.html
  54. 3
      frontend/app/features/settings/pages/contributors/contributor.component.html
  55. 4
      frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  56. 3
      frontend/app/features/settings/pages/languages/language.component.html
  57. 3
      frontend/app/features/settings/pages/patterns/pattern.component.html
  58. 1
      frontend/app/features/settings/pages/plans/plan.component.html
  59. 3
      frontend/app/features/settings/pages/roles/role.component.html
  60. 24
      frontend/app/features/settings/pages/workflows/workflow.component.html
  61. 5
      frontend/app/framework/angular/forms/confirm-click.directive.ts
  62. 4
      frontend/app/framework/angular/forms/editors/date-time-editor.component.ts
  63. 2
      frontend/app/framework/angular/forms/forms-helper.ts
  64. 2
      frontend/app/framework/angular/forms/indeterminate-value.directive.ts
  65. 2
      frontend/app/framework/angular/forms/transform-input.directive.ts
  66. 14
      frontend/app/framework/angular/modals/dialog-renderer.component.html
  67. 4
      frontend/app/framework/angular/modals/dialog-renderer.component.ts
  68. 2
      frontend/app/framework/angular/template-wrapper.directive.ts
  69. 131
      frontend/app/framework/services/dialog.service.spec.ts
  70. 58
      frontend/app/framework/services/dialog.service.ts
  71. 2
      frontend/app/framework/services/resource-loader.service.ts
  72. 2
      frontend/app/framework/utils/date-time.ts
  73. 10
      frontend/app/framework/utils/rxjs-extensions.ts
  74. 3
      frontend/app/shared/components/assets/asset-dialog.component.html
  75. 3
      frontend/app/shared/components/assets/asset-folder.component.html
  76. 12
      frontend/app/shared/components/assets/asset.component.html
  77. 1
      frontend/app/shared/components/comments/comment.component.html
  78. 2
      frontend/app/shared/components/forms/references-checkboxes.component.ts
  79. 2
      frontend/app/shared/components/forms/references-dropdown.component.ts
  80. 2
      frontend/app/shared/components/forms/references-tags.component.ts
  81. 2
      frontend/app/shared/guards/load-apps.guard.ts
  82. 2
      frontend/app/shared/guards/load-languages.guard.ts
  83. 8
      frontend/app/shared/interceptors/auth.interceptor.ts
  84. 4
      frontend/app/shared/services/assets.service.spec.ts
  85. 4
      frontend/app/shared/services/assets.service.ts
  86. 4
      frontend/app/shared/services/contents.service.spec.ts
  87. 4
      frontend/app/shared/services/contents.service.ts
  88. 2
      frontend/app/shared/services/users-provider.service.ts
  89. 34
      frontend/app/shared/state/assets.state.spec.ts
  90. 35
      frontend/app/shared/state/assets.state.ts
  91. 2
      frontend/app/shared/state/contents.forms-helpers.ts
  92. 2
      frontend/app/shared/state/contents.forms.visitors.ts
  93. 115
      frontend/app/shared/state/contents.state.ts
  94. 6
      frontend/app/shared/state/contributors.state.spec.ts
  95. 4
      frontend/app/shared/state/rule-events.state.ts
  96. 4
      frontend/app/shared/state/schemas.state.ts
  97. 2
      frontend/app/shell/pages/home/home-page.component.ts

5
backend/i18n/frontend_en.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Delete folder", "assets.deleteFolderConfirmTitle": "Delete folder",
"assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?", "assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?",
"assets.deleteMetadataConfirmTitle": "Remove 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.downloadVersion": "Download this Version",
"assets.dropToUpdate": "Drop to update", "assets.dropToUpdate": "Drop to update",
"assets.duplicateFile": "Asset has already been uploaded.", "assets.duplicateFile": "Asset has already been uploaded.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to", "common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with", "common.queryOperators.startsWith": "starts with",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Remember my decision",
"common.rename": "Rename", "common.rename": "Rename",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Delete content", "contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.", "contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", "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.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.deleteVersionFailed": "Failed to delete version. Please reload.",
"contents.draftNew": "New Draft", "contents.draftNew": "New Draft",

5
backend/i18n/frontend_it.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Elimina la cartella", "assets.deleteFolderConfirmTitle": "Elimina la cartella",
"assets.deleteMetadataConfirmText": "Sei sicuro di voler rimuovere questi metadati?", "assets.deleteMetadataConfirmText": "Sei sicuro di voler rimuovere questi metadati?",
"assets.deleteMetadataConfirmTitle": "Rimuovi 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.downloadVersion": "Scarica questa versione",
"assets.dropToUpdate": "Trascina il file per aggiornare", "assets.dropToUpdate": "Trascina il file per aggiornare",
"assets.duplicateFile": "La risorsa è già stata caricata.", "assets.duplicateFile": "La risorsa è già stata caricata.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "è uguale a", "common.queryOperators.ne": "è uguale a",
"common.queryOperators.startsWith": "inizia con", "common.queryOperators.startsWith": "inizia con",
"common.refresh": "Aggiorna", "common.refresh": "Aggiorna",
"common.remember": "Remember my decision",
"common.rename": "Rinomina", "common.rename": "Rinomina",
"common.requiredHint": "obbligatorio", "common.requiredHint": "obbligatorio",
"common.reset": "Reimposta", "common.reset": "Reimposta",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Elimina il contenuto", "contents.deleteConfirmTitle": "Elimina il contenuto",
"contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.", "contents.deleteFailed": "Non è stato possibile eliminare il contenuto. Per favore ricarica.",
"contents.deleteManyConfirmText": "Sei sicuro di voler eliminare gli elementi del contenuto selezionati?", "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.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Non è stato possibile eliminare la versione. Per favore ricarica.", "contents.deleteVersionFailed": "Non è stato possibile eliminare la versione. Per favore ricarica.",
"contents.draftNew": "Nuova bozza", "contents.draftNew": "Nuova bozza",

5
backend/i18n/frontend_nl.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Map verwijderen", "assets.deleteFolderConfirmTitle": "Map verwijderen",
"assets.deleteMetadataConfirmText": "Wil je deze metadata echt verwijderen?", "assets.deleteMetadataConfirmText": "Wil je deze metadata echt verwijderen?",
"assets.deleteMetadataConfirmTitle": "Metadata 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.downloadVersion": "Download deze versie",
"assets.dropToUpdate": "Zet neer om te updaten", "assets.dropToUpdate": "Zet neer om te updaten",
"assets.duplicateFile": "Asset is al geüpload.", "assets.duplicateFile": "Asset is al geüpload.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to", "common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with", "common.queryOperators.startsWith": "starts with",
"common.refresh": "Vernieuwen", "common.refresh": "Vernieuwen",
"common.remember": "Remember my decision",
"common.rename": "Hernoemen", "common.rename": "Hernoemen",
"common.requiredHint": "verplicht", "common.requiredHint": "verplicht",
"common.reset": "Reset", "common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Inhoud verwijderen", "contents.deleteConfirmTitle": "Inhoud verwijderen",
"contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.", "contents.deleteFailed": "Verwijderen van inhoud is mislukt. Laad opnieuw.",
"contents.deleteManyConfirmText": "Weet je zeker dat je de geselecteerde inhoudsitems wilt verwijderen?", "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.deleteVersionConfirmText": "Wil je deze versie echt verwijderen?",
"contents.deleteVersionFailed": "Verwijderen van versie is mislukt. Laad opnieuw.", "contents.deleteVersionFailed": "Verwijderen van versie is mislukt. Laad opnieuw.",
"contents.draftNew": "Nieuw concept", "contents.draftNew": "Nieuw concept",

2
backend/i18n/source/backend_en.json

@ -33,6 +33,7 @@
"assets.folderNotFound": "Asset folder does not exist.", "assets.folderNotFound": "Asset folder does not exist.",
"assets.folderRecursion": "Cannot add folder to its own child.", "assets.folderRecursion": "Cannot add folder to its own child.",
"assets.maxSizeReached": "You have reached your max asset size.", "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.alreadyRunning": "Another backup process is already running.",
"backups.maxReached": "You cannot have more than {max} backups.", "backups.maxReached": "You cannot have more than {max} backups.",
"backups.restoreRunning": "A restore operation is already running.", "backups.restoreRunning": "A restore operation is already running.",
@ -136,6 +137,7 @@
"contents.invalidNumber": "Invalid json type, expected number.", "contents.invalidNumber": "Invalid json type, expected number.",
"contents.invalidString": "Invalid json type, expected string.", "contents.invalidString": "Invalid json type, expected string.",
"contents.listReferences": "{count} Reference(s)", "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.singletonNotChangeable": "Singleton content cannot be updated.",
"contents.singletonNotCreatable": "Singleton content cannot be created.", "contents.singletonNotCreatable": "Singleton content cannot be created.",
"contents.singletonNotDeletable": "Singleton content cannot be deleted.", "contents.singletonNotDeletable": "Singleton content cannot be deleted.",

5
backend/i18n/source/frontend_en.json

@ -58,6 +58,8 @@
"assets.deleteFolderConfirmTitle": "Delete folder", "assets.deleteFolderConfirmTitle": "Delete folder",
"assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?", "assets.deleteMetadataConfirmText": "Do you really want to remove this metadata?",
"assets.deleteMetadataConfirmTitle": "Remove 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.downloadVersion": "Download this Version",
"assets.dropToUpdate": "Drop to update", "assets.dropToUpdate": "Drop to update",
"assets.duplicateFile": "Asset has already been uploaded.", "assets.duplicateFile": "Asset has already been uploaded.",
@ -286,6 +288,7 @@
"common.queryOperators.ne": "is not equals to", "common.queryOperators.ne": "is not equals to",
"common.queryOperators.startsWith": "starts with", "common.queryOperators.startsWith": "starts with",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"common.remember": "Remember my decision",
"common.rename": "Rename", "common.rename": "Rename",
"common.requiredHint": "required", "common.requiredHint": "required",
"common.reset": "Reset", "common.reset": "Reset",
@ -354,6 +357,8 @@
"contents.deleteConfirmTitle": "Delete content", "contents.deleteConfirmTitle": "Delete content",
"contents.deleteFailed": "Failed to delete content. Please reload.", "contents.deleteFailed": "Failed to delete content. Please reload.",
"contents.deleteManyConfirmText": "Do you really want to delete the selected content items?", "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.deleteVersionConfirmText": "Do you really want to delete this version?",
"contents.deleteVersionFailed": "Failed to delete version. Please reload.", "contents.deleteVersionFailed": "Failed to delete version. Please reload.",
"contents.draftNew": "New Draft", "contents.draftNew": "New Draft",

32
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<string> AssetIdField = new Lazy<string>(GetAssetIdField);
private static readonly Lazy<string> AssetFolderIdField = new Lazy<string>(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;
}
}
}

10
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -22,8 +21,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase<MongoAssetFolderEntity>, IAssetFolderRepository public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase<MongoAssetFolderEntity>, IAssetFolderRepository
{ {
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
public MongoAssetFolderRepository(IMongoDatabase database) public MongoAssetFolderRepository(IMongoDatabase database)
: base(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) await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id)
.ToListAsync(); .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; return assetFolderEntity;
} }
} }
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetFolderEntity)).GetMemberMap(nameof(MongoAssetFolderEntity.Id)).ElementName;
}
} }
} }

12
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -27,8 +26,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAssetEntity>, IAssetRepository
{ {
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
public MongoAssetRepository(IMongoDatabase database) public MongoAssetRepository(IMongoDatabase database)
: base(database) : base(database)
{ {
@ -105,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.Find(BuildFilter(appId, ids)).Only(x => x.Id) await Collection.Find(BuildFilter(appId, ids)).Only(x => x.Id)
.ToListAsync(); .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) await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.ParentId == parentId).Only(x => x.Id)
.ToListAsync(); .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.In(x => x.Id, ids),
Filter.Ne(x => x.IsDeleted, true)); Filter.Ne(x => x.IsDeleted, true));
} }
private static string GetIdField()
{
return BsonClassMap.LookupClassMap(typeof(MongoAssetEntity)).GetMemberMap(nameof(MongoAssetEntity.Id)).ElementName;
}
} }
} }

32
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<string> IdField = new Lazy<string>(GetIdField);
private static readonly Lazy<string> SchemaIdField = new Lazy<string>(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;
}
}
}

11
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 QueryContentsByIds queryContentsById;
private readonly QueryContentsByQuery queryContentsByQuery; private readonly QueryContentsByQuery queryContentsByQuery;
private readonly QueryIdsAsync queryIdsAsync; private readonly QueryIdsAsync queryIdsAsync;
private readonly QueryReferrersAsync queryReferrersAsync;
private readonly QueryScheduledContents queryScheduledItems; private readonly QueryScheduledContents queryScheduledItems;
public MongoContentCollectionAll(IMongoDatabase database, IAppProvider appProvider, ITextIndex indexer, DataConverter converter) 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); queryContentsById = new QueryContentsByIds(converter, appProvider);
queryContentsByQuery = new QueryContentsByQuery(converter, indexer); queryContentsByQuery = new QueryContentsByQuery(converter, indexer);
queryIdsAsync = new QueryIdsAsync(appProvider); queryIdsAsync = new QueryIdsAsync(appProvider);
queryReferrersAsync = new QueryReferrersAsync();
queryScheduledItems = new QueryScheduledContents(); queryScheduledItems = new QueryScheduledContents();
} }
@ -58,6 +60,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await queryContentsById.PrepareAsync(collection, ct); await queryContentsById.PrepareAsync(collection, ct);
await queryContentsByQuery.PrepareAsync(collection, ct); await queryContentsByQuery.PrepareAsync(collection, ct);
await queryIdsAsync.PrepareAsync(collection, ct); await queryIdsAsync.PrepareAsync(collection, ct);
await queryReferrersAsync.PrepareAsync(collection, ct);
await queryScheduledItems.PrepareAsync(collection, ct); await queryScheduledItems.PrepareAsync(collection, ct);
} }
@ -125,6 +128,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
} }
} }
public async Task<bool> HasReferrersAsync(Guid contentId)
{
using (Profiler.TraceMethod<MongoContentRepository>())
{
return await queryReferrersAsync.DoAsync(contentId);
}
}
public Task ResetScheduledAsync(Guid id) public Task ResetScheduledAsync(Guid id)
{ {
return Collection.UpdateOneAsync(x => x.Id == id, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt)); return Collection.UpdateOneAsync(x => x.Id == id, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt));

5
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); return collectionAll.QueryIdsAsync(appId, schemaId, filterNode);
} }
public Task<bool> HasReferrersAsync(Guid contentId)
{
return collectionAll.HasReferrersAsync(contentId);
}
public IEnumerable<IMongoCollection<MongoContentEntity>> GetInternalCollections() public IEnumerable<IMongoCollection<MongoContentEntity>> GetInternalCollections()
{ {
yield return collectionAll.GetInternalCollection(); yield return collectionAll.GetInternalCollection();

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/OperationBase.cs

@ -8,7 +8,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
@ -16,7 +15,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
protected static readonly SortDefinitionBuilder<MongoContentEntity> Sort = Builders<MongoContentEntity>.Sort; protected static readonly SortDefinitionBuilder<MongoContentEntity> Sort = Builders<MongoContentEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<MongoContentEntity> Update = Builders<MongoContentEntity>.Update; protected static readonly UpdateDefinitionBuilder<MongoContentEntity> Update = Builders<MongoContentEntity>.Update;
protected static readonly FieldDefinitionBuilder<MongoContentEntity> Fields = FieldDefinitionBuilder<MongoContentEntity>.Instance;
protected static readonly FilterDefinitionBuilder<MongoContentEntity> Filter = Builders<MongoContentEntity>.Filter; protected static readonly FilterDefinitionBuilder<MongoContentEntity> Filter = Builders<MongoContentEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<MongoContentEntity> Index = Builders<MongoContentEntity>.IndexKeys; protected static readonly IndexKeysDefinitionBuilder<MongoContentEntity> Index = Builders<MongoContentEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<MongoContentEntity> Projection = Builders<MongoContentEntity>.Projection; protected static readonly ProjectionDefinitionBuilder<MongoContentEntity> Projection = Builders<MongoContentEntity>.Projection;

17
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Bson.Serialization;
using MongoDB.Driver; using MongoDB.Driver;
using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.MongoDb.Queries; using Squidex.Infrastructure.MongoDb.Queries;
@ -21,8 +20,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
internal sealed class QueryIdsAsync : OperationBase internal sealed class QueryIdsAsync : OperationBase
{ {
private static readonly List<(Guid SchemaId, Guid Id)> EmptyIds = new List<(Guid SchemaId, Guid Id)>(); private static readonly List<(Guid SchemaId, Guid Id)> EmptyIds = new List<(Guid SchemaId, Guid Id)>();
private static readonly Lazy<string> IdField = new Lazy<string>(GetIdField);
private static readonly Lazy<string> SchemaIdField = new Lazy<string>(GetSchemaIdField);
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
public QueryIdsAsync(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) await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)
.ToListAsync(); .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<IReadOnlyList<(Guid SchemaId, Guid Id)>> DoAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode) public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> DoAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
@ -70,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId) await Collection.Find(filter).Only(x => x.Id, x => x.IndexedSchemaId)
.ToListAsync(); .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<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filterNode, Guid schemaId) public static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filterNode, Guid schemaId)
@ -88,15 +85,5 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return Filter.And(filters); 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;
}
} }
} }

45
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<MongoContentEntity>(Index
.Ascending(x => x.ReferencedIds)
.Ascending(x => x.IsDeleted));
return Collection.Indexes.CreateOneAsync(index, cancellationToken: ct);
}
public async Task<bool> 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;
}
}
}

4
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 IAppPlanBillingManager appPlansBillingManager;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
public AppDomainObject( public AppDomainObject(IStore<Guid> store, ISemanticLog log,
InitialPatterns initialPatterns, InitialPatterns initialPatterns,
IStore<Guid> store,
ISemanticLog log,
IAppPlansProvider appPlansProvider, IAppPlansProvider appPlansProvider,
IAppPlanBillingManager appPlansBillingManager, IAppPlanBillingManager appPlansBillingManager,
IUserResolver userResolver) IUserResolver userResolver)

35
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.Commands;
using Squidex.Domain.Apps.Entities.Assets.Guards; using Squidex.Domain.Apps.Entities.Assets.Guards;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -21,23 +22,29 @@ using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using IAssetTagService = Squidex.Domain.Apps.Core.Tags.ITagService;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetDomainObject : LogSnapshotDomainObject<AssetState> public class AssetDomainObject : LogSnapshotDomainObject<AssetState>
{ {
private readonly ITagService tagService; private readonly IContentRepository contentRepository;
private readonly IAssetTagService assetTags;
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
public AssetDomainObject(IStore<Guid> store, ITagService tagService, IAssetQueryService assetQuery, ISemanticLog log) public AssetDomainObject(IStore<Guid> store, ISemanticLog log,
IAssetTagService assetTags,
IAssetQueryService assetQuery,
IContentRepository contentRepository)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(tagService, nameof(tagService)); Guard.NotNull(assetTags, nameof(assetTags));
Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentRepository, nameof(contentRepository));
this.tagService = tagService; this.assetTags = assetTags;
this.assetQuery = assetQuery; this.assetQuery = assetQuery;
this.contentRepository = contentRepository;
} }
public override Task<object?> ExecuteAsync(IAggregateCommand command) public override Task<object?> ExecuteAsync(IAggregateCommand command)
@ -91,7 +98,17 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
GuardAsset.CanDelete(c); 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); Delete(c);
}); });
@ -107,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return null; 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<string>(normalized.Values); return new HashSet<string>(normalized.Values);
} }
@ -116,10 +133,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
var @event = SimpleMapper.Map(command, new AssetCreated var @event = SimpleMapper.Map(command, new AssetCreated
{ {
MimeType = command.File.MimeType,
FileName = command.File.FileName, FileName = command.File.FileName,
FileSize = command.File.FileSize, FileSize = command.File.FileSize,
FileVersion = 0, FileVersion = 0,
MimeType = command.File.MimeType,
Slug = command.File.FileName.ToAssetSlug() Slug = command.File.FileName.ToAssetSlug()
}); });
@ -132,9 +149,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
var @event = SimpleMapper.Map(command, new AssetUpdated var @event = SimpleMapper.Map(command, new AssetUpdated
{ {
MimeType = command.File.MimeType,
FileVersion = Snapshot.FileVersion + 1, FileVersion = Snapshot.FileVersion + 1,
FileSize = command.File.FileSize, FileSize = command.File.FileSize,
MimeType = command.File.MimeType
}); });
RaiseEvent(@event); RaiseEvent(@event);

3
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetFolderDomainObject.cs

@ -26,7 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
public AssetFolderDomainObject(IStore<Guid> store, IAssetQueryService assetQuery, ISemanticLog log) public AssetFolderDomainObject(ISemanticLog log, IStore<Guid> store,
IAssetQueryService assetQuery)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetQuery, nameof(assetQuery));

1
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 sealed class DeleteAsset : AssetCommand
{ {
public bool CheckReferrers { get; set; }
} }
} }

1
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 sealed class DeleteContent : ContentCommand
{ {
public bool CheckReferrers { get; set; }
} }
} }

21
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.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Guards; 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.Entities.Contents.State;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
@ -28,15 +29,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
public class ContentDomainObject : LogSnapshotDomainObject<ContentState> public class ContentDomainObject : LogSnapshotDomainObject<ContentState>
{ {
private readonly IContentWorkflow contentWorkflow; private readonly IContentWorkflow contentWorkflow;
private readonly IContentRepository contentRepository;
private readonly ContentOperationContext context; private readonly ContentOperationContext context;
public ContentDomainObject(IStore<Guid> store, IContentWorkflow contentWorkflow, ContentOperationContext context, ISemanticLog log) public ContentDomainObject(IStore<Guid> store, ISemanticLog log,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository,
ContentOperationContext context)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(contentRepository, nameof(contentRepository));
Guard.NotNull(contentWorkflow, nameof(contentWorkflow)); Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(context, nameof(context));
this.contentWorkflow = contentWorkflow; this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository;
this.context = context; 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); Delete(c);
}); });

8
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.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
#pragma warning disable IDE0016 // Use 'throw' expression #pragma warning disable IDE0016 // Use 'throw' expression
@ -43,7 +45,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
private ContentCommand command; private ContentCommand command;
private ValidationContext validationContext; private ValidationContext validationContext;
public ContentOperationContext(IAppProvider appProvider, IEnumerable<IValidatorsFactory> factories, IScriptEngine scriptEngine, ISemanticLog log) public ContentOperationContext(
IAppProvider appProvider,
IEnumerable<IValidatorsFactory> factories,
IScriptEngine scriptEngine,
ISemanticLog log)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.factories = factories; this.factories = factories;

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -28,6 +28,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids, SearchScope scope); Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> QueryIdsAsync(Guid appId, HashSet<Guid> ids, SearchScope scope);
Task<bool> HasReferrersAsync(Guid contentId);
Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope); Task<IContentEntity?> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, SearchScope scope);
Task ResetScheduledAsync(Guid contentId); Task ResetScheduledAsync(Guid contentId);

4
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 IAppProvider appProvider;
private readonly IRuleEnqueuer ruleEnqueuer; private readonly IRuleEnqueuer ruleEnqueuer;
public RuleDomainObject(IStore<Guid> store, ISemanticLog log, IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer) public RuleDomainObject(IStore<Guid> store, ISemanticLog log,
IAppProvider appProvider, IRuleEnqueuer ruleEnqueuer)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(ruleEnqueuer, nameof(ruleEnqueuer)); Guard.NotNull(ruleEnqueuer, nameof(ruleEnqueuer));
this.appProvider = appProvider; this.appProvider = appProvider;
this.ruleEnqueuer = ruleEnqueuer; this.ruleEnqueuer = ruleEnqueuer;
} }

8
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs

@ -15,10 +15,10 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IEventStore public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IEventStore
{ {
private static readonly FieldDefinition<MongoEventCommit, BsonTimestamp> TimestampField = Fields.Build(x => x.Timestamp); private static readonly FieldDefinition<MongoEventCommit, BsonTimestamp> TimestampField = FieldBuilder.Build(x => x.Timestamp);
private static readonly FieldDefinition<MongoEventCommit, long> EventsCountField = Fields.Build(x => x.EventsCount); private static readonly FieldDefinition<MongoEventCommit, long> EventsCountField = FieldBuilder.Build(x => x.EventsCount);
private static readonly FieldDefinition<MongoEventCommit, long> EventStreamOffsetField = Fields.Build(x => x.EventStreamOffset); private static readonly FieldDefinition<MongoEventCommit, long> EventStreamOffsetField = FieldBuilder.Build(x => x.EventStreamOffset);
private static readonly FieldDefinition<MongoEventCommit, string> EventStreamField = Fields.Build(x => x.EventStream); private static readonly FieldDefinition<MongoEventCommit, string> EventStreamField = FieldBuilder.Build(x => x.EventStream);
private readonly IEventNotifier notifier; private readonly IEventNotifier notifier;
public IMongoCollection<BsonDocument> RawCollection public IMongoCollection<BsonDocument> RawCollection

2
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 ReplaceOptions UpsertReplace = new ReplaceOptions { IsUpsert = true };
protected static readonly SortDefinitionBuilder<TEntity> Sort = Builders<TEntity>.Sort; protected static readonly SortDefinitionBuilder<TEntity> Sort = Builders<TEntity>.Sort;
protected static readonly UpdateDefinitionBuilder<TEntity> Update = Builders<TEntity>.Update; protected static readonly UpdateDefinitionBuilder<TEntity> Update = Builders<TEntity>.Update;
protected static readonly FieldDefinitionBuilder<TEntity> Fields = FieldDefinitionBuilder<TEntity>.Instance; protected static readonly FieldDefinitionBuilder<TEntity> FieldBuilder = FieldDefinitionBuilder<TEntity>.Instance;
protected static readonly FilterDefinitionBuilder<TEntity> Filter = Builders<TEntity>.Filter; protected static readonly FilterDefinitionBuilder<TEntity> Filter = Builders<TEntity>.Filter;
protected static readonly IndexKeysDefinitionBuilder<TEntity> Index = Builders<TEntity>.IndexKeys; protected static readonly IndexKeysDefinitionBuilder<TEntity> Index = Builders<TEntity>.IndexKeys;
protected static readonly ProjectionDefinitionBuilder<TEntity> Projection = Builders<TEntity>.Projection; protected static readonly ProjectionDefinitionBuilder<TEntity> Projection = Builders<TEntity>.Projection;

6
backend/src/Squidex.Shared/Texts.it.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve"> <data name="assets.maxSizeReached" xml:space="preserve">
<value>Hai raggiunto la dimensione massima consentito per le risorse.</value> <value>Hai raggiunto la dimensione massima consentito per le risorse.</value>
</data> </data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve"> <data name="backups.alreadyRunning" xml:space="preserve">
<value>E' in esecuzione una altro processo di backup.</value> <value>E' in esecuzione una altro processo di backup.</value>
</data> </data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve"> <data name="contents.listReferences" xml:space="preserve">
<value>{count} Collegamenti(s)</value> <value>{count} Collegamenti(s)</value>
</data> </data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve"> <data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Il contenuto singleton non può essere aggiornato</value> <value>Il contenuto singleton non può essere aggiornato</value>
</data> </data>

6
backend/src/Squidex.Shared/Texts.nl.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve"> <data name="assets.maxSizeReached" xml:space="preserve">
<value>Je hebt jouw maximale assetgrootte bereikt.</value> <value>Je hebt jouw maximale assetgrootte bereikt.</value>
</data> </data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve"> <data name="backups.alreadyRunning" xml:space="preserve">
<value>Er wordt al een ander back-upproces uitgevoerd.</value> <value>Er wordt al een ander back-upproces uitgevoerd.</value>
</data> </data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve"> <data name="contents.listReferences" xml:space="preserve">
<value>{count} referentie (s)</value> <value>{count} referentie (s)</value>
</data> </data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve"> <data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Singleton-inhoud kan niet worden bijgewerkt.</value> <value>Singleton-inhoud kan niet worden bijgewerkt.</value>
</data> </data>

6
backend/src/Squidex.Shared/Texts.resx

@ -184,6 +184,9 @@
<data name="assets.maxSizeReached" xml:space="preserve"> <data name="assets.maxSizeReached" xml:space="preserve">
<value>You have reached your max asset size.</value> <value>You have reached your max asset size.</value>
</data> </data>
<data name="assets.referenced" xml:space="preserve">
<value>Assets is referenced by a content and cannot be deleted.</value>
</data>
<data name="backups.alreadyRunning" xml:space="preserve"> <data name="backups.alreadyRunning" xml:space="preserve">
<value>Another backup process is already running.</value> <value>Another backup process is already running.</value>
</data> </data>
@ -493,6 +496,9 @@
<data name="contents.listReferences" xml:space="preserve"> <data name="contents.listReferences" xml:space="preserve">
<value>{count} Reference(s)</value> <value>{count} Reference(s)</value>
</data> </data>
<data name="contents.referenced" xml:space="preserve">
<value>Content is referenced by another content and cannot be deleted.</value>
</data>
<data name="contents.singletonNotChangeable" xml:space="preserve"> <data name="contents.singletonNotChangeable" xml:space="preserve">
<value>Singleton content cannot be updated.</value> <value>Singleton content cannot be updated.</value>
</data> </data>

5
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -284,6 +284,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="id">The id of the asset to delete.</param> /// <param name="id">The id of the asset to delete.</param>
/// <param name="checkReferrers">True to check referrers of this asset.</param>
/// <returns> /// <returns>
/// 204 => Asset deleted. /// 204 => Asset deleted.
/// 404 => Asset or app not found. /// 404 => Asset or app not found.
@ -292,9 +293,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
[Route("apps/{app}/assets/{id}/")] [Route("apps/{app}/assets/{id}/")]
[ApiPermissionOrAnonymous(Permissions.AppAssetsDelete)] [ApiPermissionOrAnonymous(Permissions.AppAssetsDelete)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> DeleteAsset(string app, Guid id) public async Task<IActionResult> 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(); return NoContent();
} }

5
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -551,6 +551,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to delete.</param> /// <param name="id">The id of the content item to delete.</param>
/// <param name="checkReferrers">True to check referrers of this content.</param>
/// <returns> /// <returns>
/// 204 => Content deleted. /// 204 => Content deleted.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
@ -562,9 +563,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ApiPermissionOrAnonymous(Permissions.AppContentsDelete)] [ApiPermissionOrAnonymous(Permissions.AppContentsDelete)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> DeleteContent(string app, string name, Guid id) public async Task<IActionResult> 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); await CommandBus.PublishAsync(command);

2
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]*") } { patternId2, new AppPattern("Numbers", "[0-9]*") }
}; };
sut = new AppDomainObject(initialPatterns, Store, A.Dummy<ISemanticLog>(), appPlansProvider, appPlansBillingManager, userResolver); sut = new AppDomainObject(Store, A.Dummy<ISemanticLog>(), initialPatterns, appPlansProvider, appPlansBillingManager, userResolver);
sut.Setup(Id); sut.Setup(Id);
} }

4
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.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -30,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>(); private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetMetadataSource assetMetadataSource = A.Fake<IAssetMetadataSource>(); private readonly IAssetMetadataSource assetMetadataSource = A.Fake<IAssetMetadataSource>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>(); private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IServiceProvider serviceProvider = A.Fake<IServiceProvider>(); private readonly IServiceProvider serviceProvider = A.Fake<IServiceProvider>();
@ -53,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
file = new NoopAssetFile(); file = new NoopAssetFile();
var assetDomainObject = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy<ISemanticLog>()); var assetDomainObject = new AssetDomainObject(Store, A.Dummy<ISemanticLog>(), tagService, assetQuery, contentRepository);
A.CallTo(() => serviceProvider.GetService(typeof(AssetDomainObject))) A.CallTo(() => serviceProvider.GetService(typeof(AssetDomainObject)))
.Returns(assetDomainObject); .Returns(assetDomainObject);

30
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.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetDomainObjectTests : HandlerTestBase<AssetState> public class AssetDomainObjectTests : HandlerTestBase<AssetState>
{ {
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly ITagService tagService = A.Fake<ITagService>(); private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly Guid parentId = Guid.NewGuid(); private readonly Guid parentId = Guid.NewGuid();
@ -46,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>._, A<HashSet<string>>._)) A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>._, A<HashSet<string>>._))
.ReturnsLazily(x => Task.FromResult(x.GetArgument<HashSet<string>>(2)?.ToDictionary(x => x)!)); .ReturnsLazily(x => Task.FromResult(x.GetArgument<HashSet<string>>(2)?.ToDictionary(x => x)!));
sut = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy<ISemanticLog>()); sut = new AssetDomainObject(Store, A.Dummy<ISemanticLog>(), tagService, assetQuery, contentRepository);
sut.Setup(Id); 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<DomainException>(() => 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() private Task ExecuteCreateAsync()
{ {
return PublishAsync(new CreateAsset { File = file }); return PublishAsync(new CreateAsset { File = file });

2
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)) A.CallTo(() => assetQuery.FindAssetFolderAsync(parentId))
.Returns(new List<IAssetFolderEntity> { A.Fake<IAssetFolderEntity>() }); .Returns(new List<IAssetFolderEntity> { A.Fake<IAssetFolderEntity>() });
sut = new AssetFolderDomainObject(Store, assetQuery, A.Dummy<ISemanticLog>()); sut = new AssetFolderDomainObject(A.Dummy<ISemanticLog>(), Store, assetQuery);
sut.Setup(Id); sut.Setup(Id);
} }

82
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.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands; 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.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
@ -35,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IAppEntity app; private readonly IAppEntity app;
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(x => x.Wrapping(new DefaultContentWorkflow())); private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>(x => x.Wrapping(new DefaultContentWorkflow()));
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly ISchemaEntity schema; private readonly ISchemaEntity schema;
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
@ -105,9 +107,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
patched = patch.MergeInto(data); patched = patch.MergeInto(data);
var context = new ContentOperationContext(appProvider, Enumerable.Repeat(new DefaultValidatorsFactory(), 1), scriptEngine, A.Fake<ISemanticLog>()); var validators = Enumerable.Repeat(new DefaultValidatorsFactory(), 1);
sut = new ContentDomainObject(Store, contentWorkflow, context, A.Dummy<ISemanticLog>()); var context = new ContentOperationContext(appProvider, validators, scriptEngine, A.Fake<ISemanticLog>());
sut = new ContentDomainObject(Store, A.Dummy<ISemanticLog>(), contentWorkflow, contentRepository, context);
sut.Setup(Id); sut.Setup(Id);
} }
@ -125,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = new CreateContent { Data = data }; var command = new CreateContent { Data = data };
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -148,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = new CreateContent { Data = data, Publish = true }; var command = new CreateContent { Data = data, Publish = true };
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -171,7 +175,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var command = new CreateContent { Data = invalidData }; var command = new CreateContent { Data = invalidData };
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(CreateContentCommand(command))); await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(command));
} }
[Fact] [Fact]
@ -181,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -205,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync(); await ExecutePublishAsync();
await ExecuteCreateDraftAsync(); await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -227,7 +231,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -244,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(CreateContentCommand(command))); await Assert.ThrowsAsync<ValidationException>(() => PublishAsync(command));
} }
[Fact] [Fact]
@ -254,7 +258,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -278,7 +282,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync(); await ExecutePublishAsync();
await ExecuteCreateDraftAsync(); await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -300,7 +304,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -317,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -339,7 +343,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -362,7 +366,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await ExecutePublishAsync(); await ExecutePublishAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -386,7 +390,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync(); await ExecutePublishAsync();
await ExecuteCreateDraftAsync(); await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -410,7 +414,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -441,7 +445,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Archived, User)) A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Archived, User))
.Returns(true); .Returns(true);
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -469,7 +473,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Published, User)) A.CallTo(() => contentWorkflow.CanMoveToAsync(A<IContentEntity>._, Status.Draft, Status.Published, User))
.Returns(false); .Returns(false);
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -491,7 +495,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(new EntitySavedResult(1)); result.ShouldBeEquivalent(new EntitySavedResult(1));
@ -506,6 +510,32 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened(); .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<DomainException>(() => 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] [Fact]
public async Task CreateDraft_should_create_events_and_update_new_state() public async Task CreateDraft_should_create_events_and_update_new_state()
{ {
@ -514,7 +544,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecuteCreateAsync(); await ExecuteCreateAsync();
await ExecutePublishAsync(); await ExecutePublishAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(sut.Snapshot); result.ShouldBeEquivalent(sut.Snapshot);
@ -535,7 +565,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ExecutePublishAsync(); await ExecutePublishAsync();
await ExecuteCreateDraftAsync(); await ExecuteCreateDraftAsync();
var result = await PublishAsync(CreateContentCommand(command)); var result = await PublishAsync(command);
result.ShouldBeEquivalent(new EntitySavedResult(3)); result.ShouldBeEquivalent(new EntitySavedResult(3));
@ -549,22 +579,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
private Task ExecuteCreateAsync() private Task ExecuteCreateAsync()
{ {
return PublishAsync(CreateContentCommand(new CreateContent { Data = data })); return PublishAsync(new CreateContent { Data = data });
} }
private Task ExecuteUpdateAsync() private Task ExecuteUpdateAsync()
{ {
return PublishAsync(CreateContentCommand(new UpdateContent { Data = otherData })); return PublishAsync(new UpdateContent { Data = otherData });
} }
private Task ExecuteCreateDraftAsync() private Task ExecuteCreateDraftAsync()
{ {
return PublishAsync(CreateContentCommand(new CreateContentDraft())); return PublishAsync(new CreateContentDraft());
} }
private Task ExecuteChangeStatusAsync(Status status, Instant? dueTime = null) 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() private Task ExecuteDeleteAsync()

9
frontend/app/features/content/pages/content/content-history-page.component.html

@ -36,7 +36,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDraftDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDraftDelete"
(sqxConfirmClick)="deleteDraft()" (sqxConfirmClick)="deleteDraft()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteVersionConfirmText"> confirmText="i18n:contents.deleteVersionConfirmText"
confirmRememberKey="deleteDraft">
{{ 'contents.versionDelete' | sqxTranslate }} {{ 'contents.versionDelete' | sqxTranslate }}
</a> </a>
@ -45,7 +46,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText"> confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>
@ -85,7 +87,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!content.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText"> confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>

3
frontend/app/features/content/pages/content/content-page.component.html

@ -36,7 +36,8 @@
<a class="dropdown-item dropdown-item-delete" <a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText"> confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>

7
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 { 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 { 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 { Observable, of } from 'rxjs';
import { debounceTime, filter, onErrorResumeNext, tap } from 'rxjs/operators'; import { debounceTime, filter, tap } from 'rxjs/operators';
@Component({ @Component({
selector: 'sqx-content-page', selector: 'sqx-content-page',
@ -192,10 +192,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
const content = this.content; const content = this.content;
if (content) { if (content) {
this.contentsState.deleteMany([content]).pipe(onErrorResumeNext()) this.contentsState.deleteMany([content]);
.subscribe(() => {
this.back();
});
} }
} }

3
frontend/app/features/content/pages/contents/contents-page.component.html

@ -55,7 +55,8 @@
<button type="button" class="btn btn-danger" *ngIf="selectionCanDelete" <button type="button" class="btn btn-danger" *ngIf="selectionCanDelete"
(sqxConfirmClick)="deleteSelected()" (sqxConfirmClick)="deleteSelected()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteManyConfirmText"> confirmText="i18n:contents.deleteManyConfirmText"
confirmRememberKey="deleteContents">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</button> </button>
</div> </div>

8
frontend/app/features/content/pages/contents/contents-page.component.ts

@ -75,7 +75,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.own( this.own(
this.route.params.pipe( this.route.params.pipe(
switchMap(x => this.schemasState.selectedSchema), distinctUntilChanged()) switchMap(() => this.schemasState.selectedSchema), distinctUntilChanged())
.subscribe(schema => { .subscribe(schema => {
this.resetSelection(); this.resetSelection();
@ -202,8 +202,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.selectionCount++; this.selectionCount++;
for (const action in this.nextStatuses) { for (const action in this.nextStatuses) {
if (!content.statusUpdates.find(x => x.status === action)) { if (this.nextStatuses.hasOwnProperty(action)) {
delete this.nextStatuses[action]; if (!content.statusUpdates.find(x => x.status === action)) {
delete this.nextStatuses[action];
}
} }
} }

4
frontend/app/features/content/pages/sidebar/sidebar-page.component.ts

@ -18,9 +18,9 @@ import { combineLatest } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class SidebarPageComponent extends ResourceOwner implements AfterViewInit { export class SidebarPageComponent extends ResourceOwner implements AfterViewInit {
private isInitialized = false; private readonly context: any;
private context: any;
private content: any; private content: any;
private isInitialized = false;
@ViewChild('iframe', { static: false }) @ViewChild('iframe', { static: false })
public iframe: ElementRef<HTMLIFrameElement>; public iframe: ElementRef<HTMLIFrameElement>;

2
frontend/app/features/content/shared/due-time-selector.component.ts

@ -17,7 +17,7 @@ const OPTION_IMMEDIATELY = 'Immediately';
templateUrl: './due-time-selector.component.html' templateUrl: './due-time-selector.component.html'
}) })
export class DueTimeSelectorComponent { export class DueTimeSelectorComponent {
private disabled: boolean; private readonly disabled: boolean;
private dueTimeResult: Subject<string | null>; private dueTimeResult: Subject<string | null>;
public dueTimeDialog = new DialogModel(); public dueTimeDialog = new DialogModel();

3
frontend/app/features/content/shared/list/content.component.html

@ -41,7 +41,8 @@
<a class="dropdown-item dropdown-item-delete" <a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="delete.emit()" (sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:contents.deleteConfirmTitle" confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText"> confirmText="i18n:contents.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>

3
frontend/app/features/content/shared/references/reference-item.component.html

@ -29,7 +29,8 @@
<button type="button" class="btn btn-text-secondary" <button type="button" class="btn btn-text-secondary"
(sqxConfirmClick)="delete.emit()" (sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:contents.removeConfirmTitle" confirmTitle="i18n:contents.removeConfirmTitle"
confirmText="i18n:contents.removeConfirmText"> confirmText="i18n:contents.removeConfirmText"
confirmRememberKey="removeReference">
<i class="icon-close"></i> <i class="icon-close"></i>
</button> </button>
</div> </div>

3
frontend/app/features/dashboard/pages/dashboard-config.component.html

@ -26,7 +26,8 @@
<a class="dropdown-item dropdown-item-delete" (beforeClick)="dropdownModal.hide()" <a class="dropdown-item dropdown-item-delete" (beforeClick)="dropdownModal.hide()"
(sqxConfirmClick)="resetConfig()" (sqxConfirmClick)="resetConfig()"
confirmTitle="i18n:dashboard.resetConfigConfirmTitle" confirmTitle="i18n:dashboard.resetConfigConfirmTitle"
confirmText="i18n:dashboard.resetConfigConfirmText"> confirmText="i18n:dashboard.resetConfigConfirmText"
confirmRememberKey="resetConfig">
{{ 'common.reset' | sqxTranslate }} {{ 'common.reset' | sqxTranslate }}
</a> </a>
</div> </div>

9
frontend/app/features/rules/pages/rules/rule.component.html

@ -20,14 +20,16 @@
<a class="dropdown-item" *ngIf="rule.canRun" <a class="dropdown-item" *ngIf="rule.canRun"
(sqxConfirmClick)="run()" (sqxConfirmClick)="run()"
confirmTitle="i18n:rules.runRuleConfirmTitle" confirmTitle="i18n:rules.runRuleConfirmTitle"
confirmText="i18n:rules.runRuleConfirmText"> confirmText="i18n:rules.runRuleConfirmText"
confirmRememberKey="runRule">
{{ 'rules.run' | sqxTranslate }} {{ 'rules.run' | sqxTranslate }}
</a> </a>
<a class="dropdown-item dropdown-item-delete" *ngIf="rule.canDelete" <a class="dropdown-item dropdown-item-delete" *ngIf="rule.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:rules.deleteConfirmTitle" confirmTitle="i18n:rules.deleteConfirmTitle"
confirmText="i18n:rules.deleteConfirmText"> confirmText="i18n:rules.deleteConfirmText"
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>
@ -58,7 +60,8 @@
<button class="btn btn-secondary" [disabled]="!rule.canTrigger" <button class="btn btn-secondary" [disabled]="!rule.canTrigger"
(sqxConfirmClick)="trigger()" (sqxConfirmClick)="trigger()"
confirmTitle="i18n:rules.triggerConfirmTitle" confirmTitle="i18n:rules.triggerConfirmTitle"
confirmText="i18n:rules.triggerConfirmText"> confirmText="i18n:rules.triggerConfirmText"
confirmRememberKey="triggerRule">
<i class="icon-play-line"></i> <i class="icon-play-line"></i>
</button> </button>
</ng-container> </ng-container>

6
frontend/app/features/schemas/pages/schema/fields/field.component.html

@ -69,7 +69,8 @@
<a class="dropdown-item" <a class="dropdown-item"
(sqxConfirmClick)="lockField()" (sqxConfirmClick)="lockField()"
confirmTitle="i18n:schemas.field.lockConfirmText" confirmTitle="i18n:schemas.field.lockConfirmText"
confirmText="i18n:schemas.field.lockConfirmText"> confirmText="i18n:schemas.field.lockConfirmText"
confirmRememberKey="lockField">
{{ 'schemas.field.lock' | sqxTranslate }} {{ 'schemas.field.lock' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>
@ -80,7 +81,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!field.canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!field.canDelete"
(sqxConfirmClick)="deleteField()" (sqxConfirmClick)="deleteField()"
confirmTitle="i18n:schemas.field.deleteConfirmTitle" confirmTitle="i18n:schemas.field.deleteConfirmTitle"
confirmText="i18n:schemas.field.deleteConfirmText"> confirmText="i18n:schemas.field.deleteConfirmText"
confirmRememberKey="deleteField">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>

3
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html

@ -29,7 +29,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable" <button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)" (sqxConfirmClick)="editForm.remove(i)"
confirmTitle="i18n:schemas.deleteUrlConfirmTitle" confirmTitle="i18n:schemas.deleteUrlConfirmTitle"
confirmText="i18n:schemas.deleteUrlConfirmText"> confirmText="i18n:schemas.deleteUrlConfirmText"
confirmRememberKey="removePreviewUrl">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

2
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts

@ -48,7 +48,7 @@ export class SchemaPreviewUrlsFormComponent implements OnChanges {
if (value) { if (value) {
this.schemasState.configurePreviewUrls(this.schema, value) this.schemasState.configurePreviewUrls(this.schema, value)
.subscribe(update => { .subscribe(() => {
this.editForm.submitCompleted({ noReset: true }); this.editForm.submitCompleted({ noReset: true });
}, error => { }, error => {
this.editForm.submitFailed(error); this.editForm.submitFailed(error);

3
frontend/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.html

@ -39,7 +39,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable" <button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)" (sqxConfirmClick)="editForm.remove(i)"
confirmTitle="i18n:schemas.deleteRuleConfirmTitle" confirmTitle="i18n:schemas.deleteRuleConfirmTitle"
confirmText="i18n:schemas.deleteRuleConfirmText"> confirmText="i18n:schemas.deleteRuleConfirmText"
confirmRememberKey="deleteFieldRule">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

3
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -40,7 +40,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!schema.canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!schema.canDelete"
(sqxConfirmClick)="deleteSchema()" (sqxConfirmClick)="deleteSchema()"
confirmTitle="i18n:schemas.deleteConfirmTitle" confirmTitle="i18n:schemas.deleteConfirmTitle"
confirmText="i18n:schemas.deleteConfirmText"> confirmText="i18n:schemas.deleteConfirmText"
confirmRememberKey="deleteSchema">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>

3
frontend/app/features/settings/pages/backups/backup.component.html

@ -40,7 +40,8 @@
<button type="button" class="btn btn-text-danger mt-1" [disabled]="!backup.canDelete" <button type="button" class="btn btn-text-danger mt-1" [disabled]="!backup.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:backups.deleteConfirmTitle" confirmTitle="i18n:backups.deleteConfirmTitle"
confirmText="i18n:backups.deleteConfirmText"> confirmText="i18n:backups.deleteConfirmText"
confirmRememberKey="deleteBackup">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

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

@ -12,7 +12,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!client.canRevoke" <button type="button" class="btn btn-text-danger" [disabled]="!client.canRevoke"
(sqxConfirmClick)="revoke()" (sqxConfirmClick)="revoke()"
confirmTitle="i18n:clients.deleteConfirmTitle" confirmTitle="i18n:clients.deleteConfirmTitle"
confirmText="i18n:clients.deleteConfirmText"> confirmText="i18n:clients.deleteConfirmText"
confirmRememberKey="revokeClient">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

3
frontend/app/features/settings/pages/contributors/contributor.component.html

@ -14,7 +14,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!contributor.canRevoke" <button type="button" class="btn btn-text-danger" [disabled]="!contributor.canRevoke"
(sqxConfirmClick)="remove()" (sqxConfirmClick)="remove()"
confirmTitle="i18n:contributors.deleteConfirmTitle" confirmTitle="i18n:contributors.deleteConfirmTitle"
confirmText="i18n:contributors.deleteConfirmText"> confirmText="i18n:contributors.deleteConfirmText"
confirmRememberKey="removeContributor">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</td> </td>

4
frontend/app/features/settings/pages/contributors/import-contributors-dialog.component.ts

@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { ContributorsState, ErrorDto, ImportContributorsForm, RoleDto } from '@app/shared'; 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'; import { catchError, mergeMap, tap } from 'rxjs/operators';
type ImportStatus = { type ImportStatus = {
@ -79,7 +79,7 @@ export class ImportContributorsDialogComponent {
status.result = 'Failed'; status.result = 'Failed';
} }
return empty(); return EMPTY;
}) })
), 1) ), 1)
).subscribe(); ).subscribe();

3
frontend/app/features/settings/pages/languages/language.component.html

@ -16,7 +16,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!language.canDelete" <button type="button" class="btn btn-text-danger" [disabled]="!language.canDelete"
(sqxConfirmClick)="remove()" (sqxConfirmClick)="remove()"
confirmTitle="i18n:languages.deleteConfirmTitle" confirmTitle="i18n:languages.deleteConfirmTitle"
confirmText="i18n:languages.deleteConfirmText"> confirmText="i18n:languages.deleteConfirmText"
confirmRememberKey="removeLanguage">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

3
frontend/app/features/settings/pages/patterns/pattern.component.html

@ -27,7 +27,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isDeletable" <button type="button" class="btn btn-text-danger" [disabled]="!isDeletable"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:patterns.deleteConfirmTitle" confirmTitle="i18n:patterns.deleteConfirmTitle"
confirmText="i18n:patterns.deleteConfirmText"> confirmText="i18n:patterns.deleteConfirmText"
confirmRememberKey="deletePattern">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

1
frontend/app/features/settings/pages/plans/plan.component.html

@ -27,6 +27,7 @@
<button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="plansState.isDisabled | async" <button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="plansState.isDisabled | async"
(sqxConfirmClick)="changeMonthly()" (sqxConfirmClick)="changeMonthly()"
confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle" confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.confirmText" [confirmText]="planInfo.plan.confirmText"
[confirmRequired]="planInfo.plan.confirmText"> [confirmRequired]="planInfo.plan.confirmText">

3
frontend/app/features/settings/pages/roles/role.component.html

@ -19,7 +19,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!role.canDelete" <button type="button" class="btn btn-text-danger" [disabled]="!role.canDelete"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:roles.deleteConfirmTitle" confirmTitle="i18n:roles.deleteConfirmTitle"
confirmText="i18n:roles.deleteConfirmText"> confirmText="i18n:roles.deleteConfirmText"
confirmRememberKey="deleteRole">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

24
frontend/app/features/settings/pages/workflows/workflow.component.html

@ -5,7 +5,11 @@
<span class="workflow-name">{{workflow.displayName}}</span> <span class="workflow-name">{{workflow.displayName}}</span>
</div> </div>
<div class="col col-tags"> <div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds" styleGray="true" styleBlank="true" singleLine="true" readonly="true"> <sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds"
styleGray="true"
styleBlank="true"
singleLine="true"
readonly="true">
</sqx-tag-editor> </sqx-tag-editor>
</div> </div>
<div class="col-options"> <div class="col-options">
@ -17,7 +21,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!workflow.canDelete" <button type="button" class="btn btn-text-danger" [disabled]="!workflow.canDelete"
(sqxConfirmClick)="remove()" (sqxConfirmClick)="remove()"
confirmTitle="i18n:workflows.deleteConfirmTitle" confirmTitle="i18n:workflows.deleteConfirmTitle"
confirmText="i18n:workflows.deleteConfirmText"> confirmText="i18n:workflows.deleteConfirmText"
confirmRememberKey="deleteWorkflow">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>
@ -62,7 +67,10 @@
<label class="col-form-label" for="{{workflow.id}}_schemas">{{ 'common.schemas' | sqxTranslate }}</label> <label class="col-form-label" for="{{workflow.id}}_schemas">{{ 'common.schemas' | sqxTranslate }}</label>
<div class="col"> <div class="col">
<sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds" (ngModelChange)="changeSchemaIds($event)" [suggestions]="(schemasSource.converter | async)?.suggestions"> <sqx-tag-editor placeholder="{{ 'common.tagAddSchema' | sqxTranslate }}" [converter]="schemasSource.converter | async"
[ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)"
[suggestions]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
<sqx-form-hint> <sqx-form-hint>
@ -71,7 +79,15 @@
</div> </div>
</div> </div>
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" [step]="step" [workflow]="workflow" [disabled]="!workflow.canUpdate" (makeInitial)="setInitial(step)" (rename)="renameStep(step, $event)" (remove)="removeStep(step)" (transitionAdd)="addTransiton(step, $event)" (transitionRemove)="removeTransition(step, $event)" (transitionUpdate)="updateTransition($event)" (update)="updateStep(step, $event)" [roles]="roles"> <sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" [step]="step" [workflow]="workflow"
[disabled]="!workflow.canUpdate"
(makeInitial)="setInitial(step)"
(rename)="renameStep(step, $event)"
(remove)="removeStep(step)"
(transitionAdd)="addTransiton(step, $event)"
(transitionRemove)="removeTransition(step, $event)"
(transitionUpdate)="updateTransition($event)"
(update)="updateStep(step, $event)" [roles]="roles">
</sqx-workflow-step> </sqx-workflow-step>
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate"> <button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate">

5
frontend/app/framework/angular/forms/confirm-click.directive.ts

@ -22,6 +22,9 @@ export class ConfirmClickDirective {
@Input() @Input()
public confirmText: string; public confirmText: string;
@Input()
public confirmRememberKey: string;
@Input() @Input()
public confirmRequired = true; public confirmRequired = true;
@ -48,7 +51,7 @@ export class ConfirmClickDirective {
this.beforeClick.emit(); this.beforeClick.emit();
this.dialogs.confirm(this.confirmTitle, this.confirmText).pipe(take(1)) this.dialogs.confirm(this.confirmTitle, this.confirmText, this.confirmRememberKey).pipe(take(1))
.subscribe(confirmed => { .subscribe(confirmed => {
if (confirmed) { if (confirmed) {
for (const observer of observers) { for (const observer of observers) {

4
frontend/app/framework/angular/forms/editors/date-time-editor.component.ts

@ -29,10 +29,10 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class DateTimeEditorComponent extends StatefulControlComponent<{}, string | null> implements OnInit, AfterViewInit, FocusComponent { export class DateTimeEditorComponent extends StatefulControlComponent<{}, string | null> implements OnInit, AfterViewInit, FocusComponent {
private readonly hideDateButtonsSettings: boolean;
private readonly hideDateTimeModeButtonSetting: boolean;
private picker: any; private picker: any;
private dateTime: DateTime | null; private dateTime: DateTime | null;
private hideDateButtonsSettings: boolean;
private hideDateTimeModeButtonSetting: boolean;
private suppressEvents = false; private suppressEvents = false;
@Input() @Input()

2
frontend/app/framework/angular/forms/forms-helper.ts

@ -33,7 +33,7 @@ export function value$<T = any>(form: AbstractControl): Observable<T> {
} }
export function valueAll$<T = any>(form: AbstractControl): Observable<T> { export function valueAll$<T = any>(form: AbstractControl): Observable<T> {
return form.valueChanges.pipe(map(_ => getRawValue(form)), startWith(getRawValue(form)), distinctUntilChanged()); return form.valueChanges.pipe(map(() => getRawValue(form)), startWith(getRawValue(form)), distinctUntilChanged());
} }
export function hasValue$(form: AbstractControl): Observable<boolean> { export function hasValue$(form: AbstractControl): Observable<boolean> {

2
frontend/app/framework/angular/forms/indeterminate-value.directive.ts

@ -20,7 +20,7 @@ export const SQX_INDETERMINATE_VALUE_CONTROL_VALUE_ACCESSOR: any = {
] ]
}) })
export class IndeterminateValueDirective implements ControlValueAccessor { export class IndeterminateValueDirective implements ControlValueAccessor {
private callChange = (v: any) => { /* NOOP */ }; private callChange = (_: any) => { /* NOOP */ };
private callTouched = () => { /* NOOP */ }; private callTouched = () => { /* NOOP */ };
constructor( constructor(

2
frontend/app/framework/angular/forms/transform-input.directive.ts

@ -29,7 +29,7 @@ export const SQX_TRANSFORM_INPUT_VALUE_ACCESSOR: any = {
] ]
}) })
export class TransformInputDirective implements ControlValueAccessor { export class TransformInputDirective implements ControlValueAccessor {
private callChange = (v: any) => { /* NOOP */ }; private callChange = (_: any) => { /* NOOP */ };
private callTouched = () => { /* NOOP */ }; private callTouched = () => { /* NOOP */ };
private transformer: Transform; private transformer: Transform;

14
frontend/app/framework/angular/modals/dialog-renderer.component.html

@ -1,13 +1,21 @@
<ng-content></ng-content> <ng-content></ng-content>
<ng-container *sqxModal="dialogView"> <ng-container *sqxModal="dialogView">
<sqx-modal-dialog showClose="false" (close)="cancel()"> <sqx-modal-dialog showClose="false" (close)="cancel()" *ngIf="snapshot.dialogRequest; let request">
<ng-container title> <ng-container title>
{{snapshot.dialogRequest?.title | sqxTranslate}} {{request.title | sqxTranslate}}
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<span [innerHTML]="snapshot.dialogRequest?.text | sqxTranslate | sqxMarkdown"></span> <span [innerHTML]="request.text | sqxTranslate | sqxMarkdown"></span>
<div class="form-check mt-4" *ngIf="request.canRemember">
<input class="form-check-input" type="checkbox" id="remember" [(ngModel)]="request.remember">
<label class="form-check-label" for="remember">
{{ 'common.remember' | sqxTranslate}}
</label>
</div>
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>

4
frontend/app/framework/angular/modals/dialog-renderer.component.ts

@ -95,9 +95,7 @@ export class DialogRendererComponent extends StatefulComponent<State> implements
private finishRequest(value: boolean) { private finishRequest(value: boolean) {
this.next(s => { this.next(s => {
if (s.dialogRequest) { s.dialogRequest?.complete(value);
s.dialogRequest.complete(value);
}
return { ...s, dialogRequest: null }; return { ...s, dialogRequest: null };
}); });

2
frontend/app/framework/angular/template-wrapper.directive.ts

@ -26,7 +26,7 @@ export class TemplateWrapperDirective implements OnDestroy, OnInit, OnChanges {
public view: EmbeddedViewRef<any>; public view: EmbeddedViewRef<any>;
public constructor( public constructor(
private viewContainer: ViewContainerRef private readonly viewContainer: ViewContainerRef
) { ) {
} }

131
frontend/app/framework/services/dialog.service.spec.ts

@ -5,18 +5,25 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { IMock, It, Mock, Times } from 'typemoq';
import { DialogRequest, DialogService, DialogServiceFactory, Notification, Tooltip } from './dialog.service'; import { DialogRequest, DialogService, DialogServiceFactory, Notification, Tooltip } from './dialog.service';
import { LocalStoreService } from './local-store.service';
describe('DialogService', () => { describe('DialogService', () => {
let localStore: IMock<LocalStoreService>;
beforeEach(() => {
localStore = Mock.ofType<LocalStoreService>();
});
it('should instantiate from factory', () => { it('should instantiate from factory', () => {
const dialogService = DialogServiceFactory(); const dialogService = DialogServiceFactory(localStore.object);
expect(dialogService).toBeDefined(); expect(dialogService).toBeDefined();
}); });
it('should instantiate', () => { it('should instantiate', () => {
const dialogService = new DialogService(); const dialogService = new DialogService(localStore.object);
expect(dialogService).toBeDefined(); expect(dialogService).toBeDefined();
}); });
@ -37,33 +44,115 @@ describe('DialogService', () => {
expect(notification.messageType).toBe('info'); expect(notification.messageType).toBe('info');
}); });
it('should create dialog request', () => { [true, false].map(confirmed => {
const dialog = new DialogRequest('MyTitle', 'MyText'); it(`should confirm dialog with ${confirmed}`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false;
let isNext: boolean;
dialogService.dialogs.subscribe(dialog => {
dialog.complete(confirmed);
});
expect(dialog.title).toBe('MyTitle'); dialogService.confirm('MyTitle', 'MyText').subscribe(result => {
expect(dialog.text).toBe('MyText'); isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never());
});
}); });
it('should confirm dialog', () => { [true, false].map(confirmed => {
const dialog = new DialogRequest('MyTitle', 'MyText'); it(`should confirm dialog with '${confirmed}' but not remember`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false; let isCompleted = false;
let isNext = false; let isNext: boolean;
dialog.closed.subscribe(result => { dialogService.dialogs.subscribe(dialog => {
isNext = result; dialog.complete(confirmed);
}, undefined, () => { });
isCompleted = true;
dialogService.confirm('MyTitle', 'MyText').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(It.isAnyString(), It.isAnyNumber()), Times.never());
}); });
});
[
{ confirmed: true, saved: 1 },
{ confirmed: false, saved: 2 }
].map(({ confirmed, saved }) => {
it(`should confirm dialog with '${confirmed}' and remember when remembered`, () => {
const dialogService = new DialogService(localStore.object);
let isCompleted = false;
let isNext: boolean;
dialog.complete(true); dialogService.dialogs.subscribe(dialog => {
dialog.remember = true;
dialog.complete(confirmed);
});
expect(isCompleted).toBeTruthy(); dialogService.confirm('MyTitle', 'MyText', 'MyKey').subscribe(result => {
expect(isNext).toBeTruthy(); isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
localStore.verify(x => x.setInt(`dialogs.confirm.MyKey`, saved), Times.once());
});
});
[
{ confirmed: true, saved: 1 },
{ confirmed: false, saved: 2 }
].map(({ confirmed, saved }) => {
it(`should confirm dialog with '${confirmed}' from local store when saved`, () => {
const dialogService = new DialogService(localStore.object);
localStore.setup(x => x.getInt(`dialogs.confirm.MyKey`))
.returns(() => saved);
let requestCount = 0;
let isCompleted = false;
let isNext: boolean;
dialogService.dialogs.subscribe(_ => {
requestCount++;
});
dialogService.confirm('MyTitle', 'MyText', 'MyKey').subscribe(result => {
isNext = result;
}, undefined, () => {
isCompleted = true;
});
expect(isCompleted).toBeTruthy();
expect(isNext!).toEqual(confirmed);
expect(requestCount).toEqual(0);
});
}); });
it('should publish tooltip', () => { it('should publish tooltip', () => {
const dialogService = new DialogService(); const dialogService = new DialogService(localStore.object);
const tooltip = new Tooltip('target', 'text', 'left'); const tooltip = new Tooltip('target', 'text', 'left');
@ -79,7 +168,7 @@ describe('DialogService', () => {
}); });
it('should publish notification', () => { it('should publish notification', () => {
const dialogService = new DialogService(); const dialogService = new DialogService(localStore.object);
const notification = Notification.error('Message'); const notification = Notification.error('Message');
@ -95,7 +184,7 @@ describe('DialogService', () => {
}); });
it('should publish dialog request', () => { it('should publish dialog request', () => {
const dialogService = new DialogService(); const dialogService = new DialogService(localStore.object);
let pushedDialog: DialogRequest; let pushedDialog: DialogRequest;
@ -105,6 +194,6 @@ describe('DialogService', () => {
dialogService.confirm('MyTitle', 'MyText'); dialogService.confirm('MyTitle', 'MyText');
expect(pushedDialog!).toEqual(new DialogRequest('MyTitle', 'MyText')); expect(pushedDialog!).toBeDefined();
}); });
}); });

58
frontend/app/framework/services/dialog.service.ts

@ -6,29 +6,56 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, Subject, throwError } from 'rxjs'; import { Observable, ReplaySubject, Subject, throwError } from 'rxjs';
import { ErrorDto } from './../utils/error'; import { ErrorDto } from './../utils/error';
import { Types } from './../utils/types'; import { Types } from './../utils/types';
import { LocalStoreService } from './local-store.service';
export const DialogServiceFactory = () => { export const DialogServiceFactory = (localStore: LocalStoreService) => {
return new DialogService(); return new DialogService(localStore);
}; };
export class DialogRequest { export class DialogRequest {
private readonly resultStream$ = new Subject<boolean>(); private readonly resultStream$ = new ReplaySubject<boolean>();
public get closed(): Observable<boolean> { public get result(): Observable<boolean> {
return this.resultStream$; return this.resultStream$;
} }
public get isCompleted() {
return this.resultStream$.isStopped;
}
public get canRemember() {
return !!this.rememberKey;
}
public remember: boolean;
constructor( constructor(
public readonly title: string, public readonly title: string,
public readonly text: string public readonly text: string,
private readonly rememberKey: string | undefined,
private readonly localStore: LocalStoreService
) { ) {
if (rememberKey) {
this.rememberKey = `dialogs.confirm.${rememberKey}`;
const isConfirmed = this.localStore.getInt(this.rememberKey);
if (isConfirmed > 0) {
this.resultStream$.next(isConfirmed === 1);
this.resultStream$.complete();
}
}
} }
public complete(value: boolean) { public complete(confirmed: boolean) {
this.resultStream$.next(value); if (this.rememberKey && this.remember) {
this.localStore.setInt(this.rememberKey, confirmed ? 1 : 2);
}
this.resultStream$.next(confirmed);
this.resultStream$.complete(); this.resultStream$.complete();
} }
} }
@ -77,6 +104,11 @@ export class DialogService {
return this.notificationsStream$; return this.notificationsStream$;
} }
constructor(
private readonly localStore: LocalStoreService
) {
}
public notifyError(error: string | ErrorDto) { public notifyError(error: string | ErrorDto) {
if (Types.is(error, ErrorDto)) { if (Types.is(error, ErrorDto)) {
this.notify(Notification.error(error)); this.notify(Notification.error(error));
@ -99,11 +131,15 @@ export class DialogService {
this.tooltipStream$.next(tooltip); this.tooltipStream$.next(tooltip);
} }
public confirm(title: string, text: string): Observable<boolean> { public confirm(title: string, text: string, rememberKey?: string): Observable<boolean> {
const request = new DialogRequest(title, text); const request = new DialogRequest(title, text, rememberKey, this.localStore);
if (request.isCompleted) {
return request.result;
}
this.requestStream$.next(request); this.requestStream$.next(request);
return request.closed; return request.result;
} }
} }

2
frontend/app/framework/services/resource-loader.service.ts

@ -22,7 +22,7 @@ export class ResourceLoaderService {
let result = this.cache[key]; let result = this.cache[key];
if (!result) { if (!result) {
result = new Promise((resolve, reject) => { result = new Promise(resolve => {
const style = this.renderer.createElement('link'); const style = this.renderer.createElement('link');
this.renderer.listen(style, 'load', () => resolve()); this.renderer.listen(style, 'load', () => resolve());

2
frontend/app/framework/utils/date-time.ts

@ -95,7 +95,7 @@ export class DateTime {
if (value.length === DATE_FORMAT.length) { if (value.length === DATE_FORMAT.length) {
date = parse(value, DATE_FORMAT, new Date()); date = parse(value, DATE_FORMAT, new Date());
} else { } else {
date = date = parseISO(value); date = parseISO(value);
} }
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {

10
frontend/app/framework/utils/rxjs-extensions.ts

@ -7,7 +7,7 @@
// tslint:disable: only-arrow-functions // tslint:disable: only-arrow-functions
import { empty, Observable } from 'rxjs'; import { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, onErrorResumeNext, publishReplay, refCount, switchMap } from 'rxjs/operators'; import { catchError, distinctUntilChanged, filter, map, onErrorResumeNext, publishReplay, refCount, switchMap } from 'rxjs/operators';
import { DialogService } from './../services/dialog.service'; import { DialogService } from './../services/dialog.service';
import { Version, versioned, Versioned } from './version'; import { Version, versioned, Versioned } from './version';
@ -20,7 +20,7 @@ export function mapVersioned<T = any, R = any>(project: (value: T, version: Vers
}; };
} }
type Options = { silent?: boolean }; type Options = { silent?: boolean, throw?: boolean };
export function shareSubscribed<T>(dialogs: DialogService, options?: Options) { export function shareSubscribed<T>(dialogs: DialogService, options?: Options) {
return shareMapSubscribed<T, T>(dialogs, x => x, options); return shareMapSubscribed<T, T>(dialogs, x => x, options);
@ -36,7 +36,11 @@ export function shareMapSubscribed<T, R = T>(dialogs: DialogService, project: (v
dialogs.notifyError(error); dialogs.notifyError(error);
} }
return empty(); if (options?.throw) {
return throwError(error);
}
return EMPTY;
})) }))
.subscribe(); .subscribe();

3
frontend/app/shared/components/assets/asset-dialog.component.html

@ -94,7 +94,8 @@
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable" <button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="annotateForm.removeMetadata(i)" (sqxConfirmClick)="annotateForm.removeMetadata(i)"
confirmTitle="i18n:assets.deleteMetadataConfirmTitle" confirmTitle="i18n:assets.deleteMetadataConfirmTitle"
confirmText="i18n:assets.deleteMetadataConfirmText"> confirmText="i18n:assets.deleteMetadataConfirmText"
confirmRememberKey="removeAssetMetadata">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

3
frontend/app/shared/components/assets/asset-folder.component.html

@ -26,7 +26,8 @@
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!canDelete"
(sqxConfirmClick)="emitDelete()" (sqxConfirmClick)="emitDelete()"
confirmTitle="i18n:assets.deleteFolderConfirmTitle" confirmTitle="i18n:assets.deleteFolderConfirmTitle"
confirmText="i18n:assets.deleteFolderConfirmText"> confirmText="i18n:assets.deleteFolderConfirmText"
confirmRememberKey="deleteAssetFolder">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</div> </div>

12
frontend/app/shared/components/assets/asset.component.html

@ -33,14 +33,16 @@
<a class="file-delete ml-2" *ngIf="!removeMode && asset.canDelete" <a class="file-delete ml-2" *ngIf="!removeMode && asset.canDelete"
(sqxConfirmClick)="delete.emit()" (sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:assets.deleteConfirmTitle" confirmTitle="i18n:assets.deleteConfirmTitle"
confirmText="i18n:assets.deleteConfirmText"> confirmText="i18n:assets.deleteConfirmText"
confirmRememberKey="deleteAsset">
<i class="icon-delete"></i> <i class="icon-delete"></i>
</a> </a>
<a class="file-delete ml-2" *ngIf="removeMode" <a class="file-delete ml-2" *ngIf="removeMode"
(sqxConfirmClick)="remove.emit()" (sqxConfirmClick)="remove.emit()"
confirmTitle="i18n:assets.removeConfirmTitle" confirmTitle="i18n:assets.removeConfirmTitle"
confirmText="i18n:assets.removeConfirmText"> confirmText="i18n:assets.removeConfirmText"
confirmRememberKey="removeAsset">
<i class="icon-close"></i> <i class="icon-close"></i>
</a> </a>
</ng-container> </ng-container>
@ -134,14 +136,16 @@
<button type="button" class="btn btn-text-danger" *ngIf="!removeMode && asset.canDelete" <button type="button" class="btn btn-text-danger" *ngIf="!removeMode && asset.canDelete"
(sqxConfirmClick)="delete.emit()" (sqxConfirmClick)="delete.emit()"
confirmTitle="i18n:assets.deleteConfirmTitle" confirmTitle="i18n:assets.deleteConfirmTitle"
confirmText="i18n:assets.deleteConfirmText"> confirmText="i18n:assets.deleteConfirmText"
confirmRememberKey="deleteAsset">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
<button type="button" class="btn btn-text-secondary" *ngIf="removeMode" <button type="button" class="btn btn-text-secondary" *ngIf="removeMode"
(sqxConfirmClick)="remove.emit()" (sqxConfirmClick)="remove.emit()"
confirmTitle="i18n:assets.removeConfirmTitle" confirmTitle="i18n:assets.removeConfirmTitle"
confirmText="i18n:assets.removeConfirmText"> confirmText="i18n:assets.removeConfirmText"
confirmRememberKey="removeAsset">
<i class="icon-close"></i> <i class="icon-close"></i>
</button> </button>
</td> </td>

1
frontend/app/shared/components/comments/comment.component.html

@ -49,6 +49,7 @@
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="i18n:comments.deleteConfirmTitle" confirmTitle="i18n:comments.deleteConfirmTitle"
confirmText="i18n:comments.deleteConfirmText" confirmText="i18n:comments.deleteConfirmText"
confirmRememberKey="deleteComment"
[confirmRequired]="confirmDelete"> [confirmRequired]="confirmDelete">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>

2
frontend/app/shared/components/forms/references-checkboxes.component.ts

@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ReferencesCheckboxesComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges { export class ReferencesCheckboxesComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private itemCount: number; private readonly itemCount: number;
private contentItems: ReadonlyArray<ContentDto> | null = null; private contentItems: ReadonlyArray<ContentDto> | null = null;
@Input() @Input()

2
frontend/app/shared/components/forms/references-dropdown.component.ts

@ -38,9 +38,9 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ReferencesDropdownComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> implements OnChanges { export class ReferencesDropdownComponent extends StatefulControlComponent<State, ReadonlyArray<string> | string> implements OnChanges {
private readonly itemCount: number;
private languageField: LanguageDto; private languageField: LanguageDto;
private selectedId: string | undefined; private selectedId: string | undefined;
private itemCount: number;
@Input() @Input()
public schemaId: string; public schemaId: string;

2
frontend/app/shared/components/forms/references-tags.component.ts

@ -31,7 +31,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges { export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private itemCount: number; private readonly itemCount: number;
private contentItems: ReadonlyArray<ContentDto> | null = null; private contentItems: ReadonlyArray<ContentDto> | null = null;
@Input() @Input()

2
frontend/app/shared/guards/load-apps.guard.ts

@ -19,6 +19,6 @@ export class LoadAppsGuard implements CanActivate {
} }
public canActivate(): Observable<boolean> { public canActivate(): Observable<boolean> {
return this.appsState.load().pipe(map(a => true)); return this.appsState.load().pipe(map(_ => true));
} }
} }

2
frontend/app/shared/guards/load-languages.guard.ts

@ -19,6 +19,6 @@ export class LoadLanguagesGuard implements CanActivate {
} }
public canActivate(): Observable<boolean> { public canActivate(): Observable<boolean> {
return this.languagesState.load().pipe(map(a => true)); return this.languagesState.load().pipe(map(_ => true));
} }
} }

8
frontend/app/shared/interceptors/auth.interceptor.ts

@ -9,13 +9,13 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ApiUrlConfig, ErrorDto } from '@app/framework'; 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 { catchError, switchMap, take } from 'rxjs/operators';
import { AuthService, Profile } from './../services/auth.service'; import { AuthService, Profile } from './../services/auth.service';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
private baseUrl: string; private readonly baseUrl: string;
constructor(apiUrlConfig: ApiUrlConfig, constructor(apiUrlConfig: ApiUrlConfig,
private readonly authService: AuthService, private readonly authService: AuthService,
@ -52,7 +52,7 @@ export class AuthInterceptor implements HttpInterceptor {
catchError(() => { catchError(() => {
this.authService.logoutRedirect(); this.authService.logoutRedirect();
return empty(); return EMPTY;
}), }),
switchMap(u => this.makeRequest(req, next, u))); switchMap(u => this.makeRequest(req, next, u)));
} else if (error.status === 401 || error.status === 403) { } else if (error.status === 401 || error.status === 403) {
@ -63,7 +63,7 @@ export class AuthInterceptor implements HttpInterceptor {
this.router.navigate(['/forbidden'], { replaceUrl: true }); this.router.navigate(['/forbidden'], { replaceUrl: true });
} }
return empty(); return EMPTY;
} else { } else {
return throwError(new ErrorDto(403, 'i18n:common.errorNoPermission')); return throwError(new ErrorDto(403, 'i18n:common.errorNoPermission'));
} }

4
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.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);

4
frontend/app/shared/services/assets.service.ts

@ -394,10 +394,10 @@ export class AssetsService {
pretifyError('i18n:assets.moveFailed')); pretifyError('i18n:assets.moveFailed'));
} }
public deleteAssetItem(appName: string, asset: Resource, version: Version): Observable<Versioned<any>> { public deleteAssetItem(appName: string, asset: Resource, checkReferrers: boolean, version: Version): Observable<Versioned<any>> {
const link = asset._links['delete']; 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( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => { tap(() => {

4
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.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);

4
frontend/app/shared/services/contents.service.ts

@ -316,10 +316,10 @@ export class ContentsService {
pretifyError(`Failed to ${status} content. Please reload.`)); pretifyError(`Failed to ${status} content. Please reload.`));
} }
public deleteContent(appName: string, resource: Resource, version: Version): Observable<Versioned<any>> { public deleteContent(appName: string, resource: Resource, checkReferrers: boolean, version: Version): Observable<Versioned<any>> {
const link = resource._links['delete']; 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( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => { tap(() => {

2
frontend/app/shared/services/users-provider.service.ts

@ -27,7 +27,7 @@ export class UsersProviderService {
if (!result) { if (!result) {
const request = const request =
this.usersService.getUser(id).pipe( this.usersService.getUser(id).pipe(
catchError(error => { catchError(() => {
return of(new UserDto('Unknown', 'Unknown')); return of(new UserDto('Unknown', 'Unknown'));
}), }),
publishLast()); publishLast());

34
frontend/app/shared/state/assets.state.spec.ts

@ -1,3 +1,4 @@
import { ErrorDto } from '@app/framework';
/* /*
* Squidex Headless CMS * Squidex Headless CMS
* *
@ -331,7 +332,7 @@ describe('AssetsState', () => {
}); });
it('should remove asset from snapshot when deleted', () => { 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))); .returns(() => of(versioned(newVersion)));
assetsState.deleteAsset(asset1).subscribe(); assetsState.deleteAsset(asset1).subscribe();
@ -341,8 +342,37 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.tagsAvailable).toEqual({ shared: 1, tag2: 1 }); 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', () => { 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))); .returns(() => of(versioned(newVersion)));
assetsState.deleteAssetFolder(assetFolder1).subscribe(); assetsState.deleteAssetFolder(assetFolder1).subscribe();

35
frontend/app/shared/state/assets.state.ts

@ -6,9 +6,9 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { compareStrings, DialogService, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework'; import { compareStrings, DialogService, ErrorDto, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { empty, forkJoin, Observable, of, throwError } from 'rxjs'; import { EMPTY, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, switchMap, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { Query, QueryFullTextSynchronizer } from './query'; import { Query, QueryFullTextSynchronizer } from './query';
@ -274,7 +274,7 @@ export class AssetsState extends State<Snapshot> {
public moveAsset(asset: AssetDto, parentId?: string) { public moveAsset(asset: AssetDto, parentId?: string) {
if (asset.parentId === parentId) { if (asset.parentId === parentId) {
return empty(); return EMPTY;
} }
this.next(s => { this.next(s => {
@ -298,7 +298,7 @@ export class AssetsState extends State<Snapshot> {
public moveAssetFolder(assetFolder: AssetFolderDto, parentId?: string) { public moveAssetFolder(assetFolder: AssetFolderDto, parentId?: string) {
if (assetFolder.id === parentId || assetFolder.parentId === parentId) { if (assetFolder.id === parentId || assetFolder.parentId === parentId) {
return empty(); return EMPTY;
} }
this.next(s => { this.next(s => {
@ -320,8 +320,27 @@ export class AssetsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public deleteAsset(asset: AssetDto): Observable<any> { public deleteAsset(asset: AssetDto) {
return this.assetsService.deleteAssetItem(this.appName, asset, asset.version).pipe( 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(() => { tap(() => {
this.next(s => { this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id); const assets = s.assets.filter(x => x.id !== asset.id);
@ -336,7 +355,7 @@ export class AssetsState extends State<Snapshot> {
} }
public deleteAssetFolder(assetFolder: AssetFolderDto): Observable<any> { public deleteAssetFolder(assetFolder: AssetFolderDto): Observable<any> {
return this.assetsService.deleteAssetItem(this.appName, assetFolder, assetFolder.version).pipe( return this.assetsService.deleteAssetItem(this.appName, assetFolder, false, assetFolder.version).pipe(
tap(() => { tap(() => {
this.next(s => { this.next(s => {
const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id); const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id);

2
frontend/app/shared/state/contents.forms-helpers.ts

@ -71,7 +71,7 @@ export class PartitionConfig {
} }
export class CompiledRule { export class CompiledRule {
private function: Function; private readonly function: Function;
public get field() { public get field() {
return this.rule.field; return this.rule.field;

2
frontend/app/shared/state/contents.forms.visitors.ts

@ -39,7 +39,7 @@ export function getContentValue(content: ContentDto, language: LanguageDto, fiel
fieldValue = reference[fieldInvariant]; fieldValue = reference[fieldInvariant];
} }
let value: string | undefined = undefined; let value: string | undefined;
if (Types.isObject(fieldValue)) { if (Types.isObject(fieldValue)) {
value = fieldValue[language.iso2Code]; value = fieldValue[language.iso2Code];

115
frontend/app/shared/state/contents.state.ts

@ -7,8 +7,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, Pager, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework'; import { DialogService, ErrorDto, Pager, shareSubscribed, State, StateSynchronizer, Types, Version, Versioned } from '@app/framework';
import { empty, forkJoin, Observable, of } from 'rxjs'; import { EMPTY, forkJoin, Observable, of } from 'rxjs';
import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; import { catchError, finalize, map, switchMap, tap } from 'rxjs/operators';
import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service'; import { ContentDto, ContentsService, StatusInfo } from './../services/contents.service';
import { SchemaDto } from './../services/schemas.service'; import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
@ -16,6 +16,8 @@ import { SavedQuery } from './queries';
import { Query, QuerySynchronizer } from './query'; import { Query, QuerySynchronizer } from './query';
import { SchemasState } from './schemas.state'; import { SchemasState } from './schemas.state';
type Updated = { content: ContentDto, error?: ErrorDto };
interface Snapshot { interface Snapshot {
// The current comments. // The current comments.
contents: ReadonlyArray<ContentDto>; contents: ReadonlyArray<ContentDto>;
@ -141,7 +143,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public loadIfNotLoaded(): Observable<any> { public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) { if (this.snapshot.isLoaded) {
return empty(); return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false);
@ -153,7 +155,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
private loadInternalCore(isReload: boolean) { private loadInternalCore(isReload: boolean) {
if (!this.appName || !this.schemaName) { if (!this.appName || !this.schemaName) {
return empty(); return EMPTY;
} }
this.next({ isLoading: true }); this.next({ isLoading: true });
@ -221,39 +223,82 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs, {silent: true})); shareSubscribed(this.dialogs, {silent: true}));
} }
public changeManyStatus(contents: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<any> { public changeManyStatus(contentsToChange: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<any> {
return forkJoin( return this.updateManyStatus(contentsToChange, status, dueTime).pipe(
contents.map(c =>
this.contentsService.putStatus(this.appName, c, status, dueTime, c.version).pipe(
catchError(error => of(error))))).pipe(
tap(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) { if (errors.length === contentsToChange.length) {
this.dialogs.notifyError(error); 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));
shareSubscribed(this.dialogs, { silent: true }));
} }
public deleteMany(contents: ReadonlyArray<ContentDto>): Observable<any> { public deleteMany(contentsToDelete: ReadonlyArray<ContentDto>) {
return forkJoin( return this.deleteManyCore(contentsToDelete, true).pipe(
contents.map(c => switchMap(results => {
this.contentsService.deleteContent(this.appName, c, c.version).pipe( const referenced = results.filter(x => x.error?.statusCode === 400).map(x => x.content);
catchError(error => of(error))))).pipe(
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 => { 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) { if (errors.length === contentsToDelete.length) {
this.dialogs.notifyError(error); 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 })); shareSubscribed(this.dialogs, { silent: true }));
} }
@ -329,6 +374,26 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
} }
private deleteManyCore(contents: ReadonlyArray<ContentDto>, checkReferrers: boolean): Observable<ReadonlyArray<Updated>> {
return forkJoin(
contents.map(c => this.deleteCore(c, checkReferrers)));
}
private updateManyStatus(contents: ReadonlyArray<ContentDto>, status: string, dueTime: string | null): Observable<ReadonlyArray<Updated>> {
return forkJoin(
contents.map(c => this.updateStatus(c, status, dueTime)));
}
private deleteCore(content: ContentDto, checkReferrers: boolean): Observable<Updated> {
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<Updated> {
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 schemaId(): string;
public abstract get schemaName(): string; public abstract get schemaName(): string;

6
frontend/app/shared/state/contributors.state.spec.ts

@ -7,7 +7,7 @@
import { ErrorDto } from '@app/framework'; import { ErrorDto } from '@app/framework';
import { ContributorDto, ContributorsPayload, ContributorsService, ContributorsState, DialogService, Pager, versioned } from '@app/shared/internal'; 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 { catchError, onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { createContributors } from './../services/contributors.service.spec'; import { createContributors } from './../services/contributors.service.spec';
@ -164,7 +164,7 @@ describe('ContributorsState', () => {
catchError(err => { catchError(err => {
error = err; error = err;
return empty(); return EMPTY;
}) })
).subscribe(); ).subscribe();
@ -183,7 +183,7 @@ describe('ContributorsState', () => {
catchError(err => { catchError(err => {
error = err; error = err;
return empty(); return EMPTY;
}) })
).subscribe(); ).subscribe();

4
frontend/app/shared/state/rule-events.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DialogService, Pager, Router2State, shareSubscribed, State } from '@app/framework'; 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 { finalize, tap } from 'rxjs/operators';
import { RuleEventDto, RulesService } from './../services/rules.service'; import { RuleEventDto, RulesService } from './../services/rules.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
@ -122,7 +122,7 @@ export class RuleEventsState extends State<Snapshot> {
public filterByRule(ruleId?: string) { public filterByRule(ruleId?: string) {
if (ruleId === this.snapshot.ruleId) { if (ruleId === this.snapshot.ruleId) {
return empty(); return EMPTY;
} }
this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.reset(), ruleId })); this.next(s => ({ ...s, ruleEventsPager: s.ruleEventsPager.reset(), ruleId }));

4
frontend/app/shared/state/schemas.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { compareStrings, defined, DialogService, shareMapSubscribed, shareSubscribed, State, Types, Version } from '@app/framework'; 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 { catchError, finalize, tap } from 'rxjs/operators';
import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service'; import { AddFieldDto, CreateSchemaDto, FieldDto, FieldRule, NestedFieldDto, RootFieldDto, SchemaDetailsDto, SchemaDto, SchemasService, UpdateFieldDto, UpdateSchemaDto, UpdateUIFields } from './../services/schemas.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
@ -125,7 +125,7 @@ export class SchemasState extends State<Snapshot> {
public loadIfNotLoaded(): Observable<any> { public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) { if (this.snapshot.isLoaded) {
return empty(); return EMPTY;
} }
return this.loadInternal(false); return this.loadInternal(false);

2
frontend/app/shell/pages/home/home-page.component.ts

@ -30,7 +30,7 @@ export class HomePageComponent {
this.authService.loginPopup() this.authService.loginPopup()
.subscribe(() => { .subscribe(() => {
this.router.navigate(['/app']); this.router.navigate(['/app']);
}, error => { }, _ => {
this.showLoginError = true; this.showLoginError = true;
}); });
} }

Loading…
Cancel
Save