Browse Source

Feature/parent path (#673)

* Docs fixed

* Parent path.

* Fixes.

* Parent path.

* Just a renaming.

* API cleanup.

* Some tests.

* Tests

* Tests fixed

* Delete unused test.
pull/679/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
9f604a4e40
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      backend/i18n/frontend_en.json
  2. 5
      backend/i18n/frontend_it.json
  3. 5
      backend/i18n/frontend_nl.json
  4. 7
      backend/i18n/source/frontend_en.json
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs
  7. 6
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppLanguages.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/BulkUpdateJob.cs
  9. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs
  10. 10
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/IMoveAssetCommand.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAsset.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpsertAsset.cs
  13. 31
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  14. 17
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
  16. 117
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderResolver.cs
  17. 39
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/GuardAsset.cs
  18. 5
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/GuardAssetFolder.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  20. 10
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  21. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentExtensions.cs
  22. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs
  23. 259
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
  24. 235
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentOperationContext.cs
  25. 201
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/GuardContent.cs
  26. 122
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs
  27. 31
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs
  28. 40
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SingletonExtensions.cs
  29. 129
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ValidationExtensions.cs
  30. 78
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs
  31. 122
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/OperationContext.cs
  32. 15
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs
  33. 6
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  34. 30
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs
  35. 66
      backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs
  36. 4
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs
  37. 8
      backend/src/Squidex.Infrastructure/Commands/DomainObject.cs
  38. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsDto.cs
  39. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs
  40. 6
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs
  41. 5
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/MoveAssetDto.cs
  42. 6
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs
  43. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs
  44. 5
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs
  45. 3
      backend/src/Squidex/Config/Domain/AssetServices.cs
  46. 3
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  47. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs
  48. 47
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs
  49. 165
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetFolderResolverTests.cs
  50. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs
  51. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/Guards/GuardAssetFolderTests.cs
  52. 57
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/Guards/GuardAssetTests.cs
  53. 15
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs
  54. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
  55. 309
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs
  56. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs
  57. 32
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/Guards/GuardSchemaTests.cs
  58. 20
      backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs
  59. 8
      frontend/app/features/assets/pages/asset-tags.component.ts
  60. 2
      frontend/app/features/content/shared/forms/assets-editor.component.html
  61. 5
      frontend/app/features/content/shared/forms/assets-editor.component.ts
  62. 14
      frontend/app/features/content/shared/forms/field-editor.component.html
  63. 6
      frontend/app/features/content/shared/forms/stock-photo-editor.component.ts
  64. 12
      frontend/app/features/schemas/pages/schema/fields/types/assets-ui.component.html
  65. 3
      frontend/app/features/schemas/pages/schema/fields/types/assets-ui.component.ts
  66. 15
      frontend/app/features/schemas/pages/schema/fields/types/string-ui.component.html
  67. 3
      frontend/app/features/schemas/pages/schema/fields/types/string-ui.component.ts
  68. 4
      frontend/app/features/schemas/pages/schema/fields/types/string-validation.component.html
  69. 1
      frontend/app/framework/angular/forms/editors/autocomplete.component.ts
  70. 12
      frontend/app/framework/angular/forms/editors/code-editor.component.ts
  71. 22
      frontend/app/framework/angular/forms/editors/dropdown.component.ts
  72. 6
      frontend/app/framework/angular/forms/editors/tag-editor.component.ts
  73. 0
      frontend/app/framework/angular/pipes/highlight.pipe.ts
  74. 2
      frontend/app/framework/declarations.ts
  75. 5
      frontend/app/shared/components/assets/asset-folder-dropdown.component.html
  76. 0
      frontend/app/shared/components/assets/asset-folder-dropdown.component.scss
  77. 76
      frontend/app/shared/components/assets/asset-folder-dropdown.component.ts
  78. 5
      frontend/app/shared/components/assets/asset.component.ts
  79. 7
      frontend/app/shared/components/forms/markdown-editor.component.ts
  80. 2
      frontend/app/shared/components/forms/references-checkboxes.component.html
  81. 18
      frontend/app/shared/components/forms/references-checkboxes.component.ts
  82. 2
      frontend/app/shared/components/forms/references-dropdown.component.html
  83. 22
      frontend/app/shared/components/forms/references-dropdown.component.ts
  84. 3
      frontend/app/shared/components/forms/references-tags.component.html
  85. 14
      frontend/app/shared/components/forms/references-tags.component.ts
  86. 7
      frontend/app/shared/components/forms/rich-editor.component.ts
  87. 1
      frontend/app/shared/declarations.ts
  88. 3
      frontend/app/shared/module.ts
  89. 14
      frontend/app/shared/services/assets.service.spec.ts
  90. 5
      frontend/app/shared/services/assets.service.ts
  91. 2
      frontend/app/shared/services/schemas.types.ts
  92. 6
      frontend/app/shared/state/asset-uploader.state.ts
  93. 2
      frontend/app/shared/state/assets.state.ts

7
backend/i18n/frontend_en.json

@ -97,7 +97,7 @@
"assets.searchByTags": "Search by tags",
"assets.selectMany": "Select assets",
"assets.specialFolder.parent": "<Parent>",
"assets.specialFolder.root": "Assets",
"assets.specialFolder.root": "<Root>",
"assets.tabFocusPoint": "Focus Point",
"assets.tabHistory": "History",
"assets.tabImage": "Image",
@ -314,6 +314,7 @@
"common.save": "Save",
"common.saveShortcut": "CTRL + S",
"common.schemas": "Schemas",
"common.search": "Search",
"common.searchGoogleMaps": "Search Google Maps",
"common.searchResults": "Search Results",
"common.separateByLine": "Separate by line",
@ -734,6 +735,8 @@
"schemas.fieldTypes.assets.countMin": "Min Assets",
"schemas.fieldTypes.assets.description": "Images, videos, documents.",
"schemas.fieldTypes.assets.fileExtensions": "File Extensions",
"schemas.fieldTypes.assets.folderId": "Folder",
"schemas.fieldTypes.assets.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.assets.mustBeImage": "Must be Image",
"schemas.fieldTypes.assets.previewFileName": "Only file name",
"schemas.fieldTypes.assets.previewImage": "Only thumbnail or file name if not an image",
@ -767,6 +770,8 @@
"schemas.fieldTypes.string.charactersMin": "Min Characters",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titles, names, paragraphs.",
"schemas.fieldTypes.string.folderId": "Asset folder",
"schemas.fieldTypes.string.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.string.length": "Length",
"schemas.fieldTypes.string.lengthMax": "Max Length",
"schemas.fieldTypes.string.lengthMin": "Min Length",

5
backend/i18n/frontend_it.json

@ -314,6 +314,7 @@
"common.save": "Salva",
"common.saveShortcut": "CTRL + S",
"common.schemas": "Schemi",
"common.search": "Search",
"common.searchGoogleMaps": "Cerca su Google Maps",
"common.searchResults": "Risultati di ricerca",
"common.separateByLine": "Separato dalla linea",
@ -734,6 +735,8 @@
"schemas.fieldTypes.assets.countMin": "Min num. di Risorse",
"schemas.fieldTypes.assets.description": "Immagini, video, documenti.",
"schemas.fieldTypes.assets.fileExtensions": "Estensioni dei File",
"schemas.fieldTypes.assets.folderId": "Folder",
"schemas.fieldTypes.assets.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.assets.mustBeImage": "Deve essere un'immagine",
"schemas.fieldTypes.assets.previewFileName": "Solamente il nome del file",
"schemas.fieldTypes.assets.previewImage": "Solamente l'anteprima o il nome del file se non è un immagine",
@ -767,6 +770,8 @@
"schemas.fieldTypes.string.charactersMin": "Min numero di Caratteri",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titoli, nomi, paragrafi.",
"schemas.fieldTypes.string.folderId": "Asset folder",
"schemas.fieldTypes.string.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.string.length": "Lunghezza",
"schemas.fieldTypes.string.lengthMax": "Lunghezza Max",
"schemas.fieldTypes.string.lengthMin": "Lunghezza Min",

5
backend/i18n/frontend_nl.json

@ -314,6 +314,7 @@
"common.save": "Opslaan",
"common.saveShortcut": "CTRL + S",
"common.schemas": "Schema's",
"common.search": "Search",
"common.searchGoogleMaps": "Zoeken in Google Maps",
"common.searchResults": "Zoekresultaten",
"common.separateByLine": "Scheiden op regel",
@ -734,6 +735,8 @@
"schemas.fieldTypes.assets.countMin": "Min. bestanden",
"schemas.fieldTypes.assets.description": "Afbeeldingen, video's, documenten.",
"schemas.fieldTypes.assets.fileExtensions": "Bestandsextensies",
"schemas.fieldTypes.assets.folderId": "Folder",
"schemas.fieldTypes.assets.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.assets.mustBeImage": "Moet afbeelding zijn",
"schemas.fieldTypes.assets.previewFileName": "Alleen bestandsnaam",
"schemas.fieldTypes.assets.previewImage": "Alleen miniatuur- of bestandsnaam indien geen afbeelding",
@ -767,6 +770,8 @@
"schemas.fieldTypes.string.charactersMin": "Min. karakters",
"schemas.fieldTypes.string.contentType": "Inhoudstype",
"schemas.fieldTypes.string.description": "Titels, namen, alinea's.",
"schemas.fieldTypes.string.folderId": "Asset folder",
"schemas.fieldTypes.string.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.string.length": "Lengte",
"schemas.fieldTypes.string.lengthMax": "Max. lengte",
"schemas.fieldTypes.string.lengthMin": "Min. lengte",

7
backend/i18n/source/frontend_en.json

@ -97,7 +97,7 @@
"assets.searchByTags": "Search by tags",
"assets.selectMany": "Select assets",
"assets.specialFolder.parent": "<Parent>",
"assets.specialFolder.root": "Assets",
"assets.specialFolder.root": "<Root>",
"assets.tabFocusPoint": "Focus Point",
"assets.tabHistory": "History",
"assets.tabImage": "Image",
@ -314,6 +314,7 @@
"common.save": "Save",
"common.saveShortcut": "CTRL + S",
"common.schemas": "Schemas",
"common.search": "Search",
"common.searchGoogleMaps": "Search Google Maps",
"common.searchResults": "Search Results",
"common.separateByLine": "Separate by line",
@ -734,6 +735,8 @@
"schemas.fieldTypes.assets.countMin": "Min Assets",
"schemas.fieldTypes.assets.description": "Images, videos, documents.",
"schemas.fieldTypes.assets.fileExtensions": "File Extensions",
"schemas.fieldTypes.assets.folderId": "Folder",
"schemas.fieldTypes.assets.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.assets.mustBeImage": "Must be Image",
"schemas.fieldTypes.assets.previewFileName": "Only file name",
"schemas.fieldTypes.assets.previewImage": "Only thumbnail or file name if not an image",
@ -767,6 +770,8 @@
"schemas.fieldTypes.string.charactersMin": "Min Characters",
"schemas.fieldTypes.string.contentType": "Content Type",
"schemas.fieldTypes.string.description": "Titles, names, paragraphs.",
"schemas.fieldTypes.string.folderId": "Asset folder",
"schemas.fieldTypes.string.folderIdHint": "The asset folder where the new assets will be uploaded to.",
"schemas.fieldTypes.string.length": "Length",
"schemas.fieldTypes.string.lengthMax": "Max Length",
"schemas.fieldTypes.string.lengthMin": "Min Length",

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs

@ -18,6 +18,8 @@ namespace Squidex.Domain.Apps.Core.Schemas
public string[]? DefaultValue { get; set; }
public string? FolderId { get; set; }
public int? MinItems { get; set; }
public int? MaxItems { get; set; }

2
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs

@ -22,6 +22,8 @@ namespace Squidex.Domain.Apps.Core.Schemas
public string? PatternMessage { get; set; }
public string? FolderId { get; set; }
public int? MinLength { get; set; }
public int? MaxLength { get; set; }

6
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppLanguages.cs

@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
}
else
{
EnsureConfigExists(languages, language);
CheckLanguageExists(languages, language);
if (languages.IsMaster(language))
{
@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
}
else
{
EnsureConfigExists(languages, language);
CheckLanguageExists(languages, language);
if (languages.IsMaster(language) || command.IsMaster)
{
@ -106,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
});
}
private static void EnsureConfigExists(LanguagesConfig languages, Language language)
private static void CheckLanguageExists(LanguagesConfig languages, Language language)
{
if (!languages.Contains(language))
{

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/BulkUpdateJob.cs

@ -19,8 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
public DomainId ParentId { get; set; }
public string? ParentPath { get; set; }
public string? FileName { get; set; }
public string? Slug { get; set; }

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs

@ -10,12 +10,10 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class CreateAsset : UploadAssetCommand
public sealed class CreateAsset : UploadAssetCommand, IMoveAssetCommand
{
public DomainId ParentId { get; set; }
public string? ParentPath { get; set; }
public bool Duplicate { get; set; }
public CreateAsset()

10
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/IAssetFolderResolver.cs → backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/IMoveAssetCommand.cs

@ -1,18 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public interface IAssetFolderResolver
public interface IMoveAssetCommand : IAppCommand
{
Task<DomainId> ResolveOrCreateAsync(Context context, ICommandBus commandBus, string path);
DomainId ParentId { get; set; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/MoveAsset.cs

@ -13,6 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public DomainId ParentId { get; set; }
public string? ParentPath { get; set; }
public bool OptimizeValidation { get; set; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpsertAsset.cs

@ -14,8 +14,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public DomainId? ParentId { get; set; }
public string? ParentPath { get; set; }
public UpsertAsset()
{
AssetId = DomainId.NewGuid();

31
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -19,7 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
{
private readonly IAssetFileStore assetFileStore;
private readonly IAssetFolderResolver assetFolderResolver;
private readonly IAssetEnricher assetEnricher;
private readonly IAssetQueryService assetQuery;
private readonly IContextProvider contextProvider;
@ -29,7 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
IGrainFactory grainFactory,
IAssetEnricher assetEnricher,
IAssetFileStore assetFileStore,
IAssetFolderResolver assetFolderResolver,
IAssetQueryService assetQuery,
IContextProvider contextProvider,
IEnumerable<IAssetMetadataSource> assetMetadataSources)
@ -37,14 +35,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
Guard.NotNull(assetEnricher, nameof(assetEnricher));
Guard.NotNull(assetFileStore, nameof(assetFileStore));
Guard.NotNull(assetFolderResolver, nameof(assetFolderResolver));
Guard.NotNull(assetMetadataSources, nameof(assetMetadataSources));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contextProvider, nameof(contextProvider));
this.assetEnricher = assetEnricher;
this.assetFileStore = assetFileStore;
this.assetFolderResolver = assetFolderResolver;
this.assetMetadataSources = assetMetadataSources;
this.assetQuery = assetQuery;
this.contextProvider = contextProvider;
@ -79,15 +75,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
}
if (!string.IsNullOrWhiteSpace(createAsset.ParentPath))
{
createAsset.ParentId =
await assetFolderResolver.ResolveOrCreateAsync(
contextProvider.Context,
context.CommandBus,
createAsset.ParentPath);
}
await EnrichWithMetadataAsync(createAsset);
await base.HandleAsync(context, next);
@ -104,15 +91,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
case MoveAsset move:
{
if (!string.IsNullOrWhiteSpace(move.ParentPath))
{
move.ParentId =
await assetFolderResolver.ResolveOrCreateAsync(
contextProvider.Context,
context.CommandBus,
move.ParentPath);
}
await base.HandleAsync(context, next);
break;
@ -120,15 +98,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
case UpsertAsset upsert:
{
if (!string.IsNullOrWhiteSpace(upsert.ParentPath))
{
upsert.ParentId =
await assetFolderResolver.ResolveOrCreateAsync(
contextProvider.Context,
context.CommandBus,
upsert.ParentPath);
}
await UploadAndHandleAsync(context, next, upsert);
break;

17
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs

@ -109,8 +109,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
case AnnotateAsset c:
return UpdateReturnAsync(c, async c =>
{
GuardAsset.CanAnnotate(c);
if (c.Tags != null)
{
c.Tags = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
@ -155,8 +153,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
private async Task CreateCore(CreateAsset create)
{
GuardAsset.CanCreate(create);
if (create.Tags != null)
{
create.Tags = await NormalizeTagsAsync(create.AppId.Id, create.Tags);
@ -165,18 +161,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Create(create);
}
private async Task MoveCore(MoveAsset move)
private void UpdateCore(UpdateAsset update)
{
await GuardAsset.CanMove(move, Snapshot, assetQuery);
Move(move);
Update(update);
}
private void UpdateCore(UpdateAsset update)
private async Task MoveCore(MoveAsset move)
{
GuardAsset.CanUpdate(update);
await GuardAsset.CanMove(move, Snapshot, assetQuery);
Update(update);
Move(move);
}
private async Task DeleteCore(DeleteAsset delete)
@ -202,7 +196,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
MimeType = command.File.MimeType,
FileName = command.File.FileName,
FileSize = command.File.FileSize,
FileVersion = 0,
Slug = command.File.FileName.ToAssetSlug()
});
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs

@ -87,8 +87,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
case DeleteAssetFolder delete:
return Update(delete, c =>
{
GuardAssetFolder.CanDelete(c);
Delete(c);
});

117
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderResolver.cs

@ -1,117 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Caching;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
public sealed class AssetFolderResolver : IAssetFolderResolver
{
private static readonly char[] TrimChars = { '/', '\\' };
private static readonly char[] SplitChars = { ' ', '/', '\\' };
private readonly ILocalCache localCache;
private readonly IAssetQueryService assetQuery;
public AssetFolderResolver(ILocalCache localCache, IAssetQueryService assetQuery)
{
Guard.NotNull(localCache, nameof(localCache));
Guard.NotNull(assetQuery, nameof(assetQuery));
this.localCache = localCache;
this.assetQuery = assetQuery;
}
public async Task<DomainId> ResolveOrCreateAsync(Context context, ICommandBus commandBus, string path)
{
Guard.NotNull(commandBus, nameof(commandBus));
Guard.NotNull(path, nameof(path));
path = path.Trim(TrimChars);
var elements = path.Split(SplitChars, StringSplitOptions.RemoveEmptyEntries);
if (elements.Length == 0)
{
return DomainId.Empty;
}
var currentId = DomainId.Empty;
var i = elements.Length;
for (; i > 0; i--)
{
var subPath = string.Join('/', elements.Take(i));
if (localCache.TryGetValue(GetCacheKey(subPath), out var cached) && cached is DomainId id)
{
currentId = id;
break;
}
}
var creating = false;
for (; i < elements.Length; i++)
{
var name = elements[i];
var isResolved = false;
if (!creating)
{
var children = await assetQuery.QueryAssetFoldersAsync(context, currentId);
foreach (var child in children)
{
var childPath = string.Join('/', elements.Take(i).Union(Enumerable.Repeat(child.FolderName, 1)));
localCache.Add(GetCacheKey(childPath), child.Id);
}
foreach (var child in children)
{
if (child.FolderName == name)
{
currentId = child.Id;
isResolved = true;
break;
}
}
}
if (!isResolved)
{
var command = new CreateAssetFolder { ParentId = currentId, FolderName = name };
await commandBus.PublishAsync(command);
currentId = command.AssetFolderId;
creating = true;
}
var newPath = string.Join('/', elements.Take(i).Union(Enumerable.Repeat(name, 1)));
localCache.Add(GetCacheKey(newPath), currentId);
}
return currentId;
}
private static object GetCacheKey(string path)
{
return $"ASSET_FOLDERS_{path}";
}
}
}

39
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/GuardAsset.cs

@ -17,34 +17,26 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
{
public static class GuardAsset
{
public static void CanAnnotate(AnnotateAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanCreate(CreateAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static Task CanMove(MoveAsset command, IAssetEntity asset, IAssetQueryService assetQuery)
{
Guard.NotNull(command, nameof(command));
return Validate.It(async e =>
{
if (command.ParentId != asset.ParentId)
var parentId = command.ParentId;
if (parentId != asset.ParentId && parentId != DomainId.Empty && !command.OptimizeValidation)
{
await CheckPathAsync(command.AppId.Id, command.ParentId, assetQuery, e);
var path = await assetQuery.FindAssetFolderAsync(command.AppId.Id, parentId);
if (path.Count == 0)
{
e(T.Get("assets.folderNotFound"), nameof(MoveAsset.ParentId));
}
}
});
}
public static void CanUpdate(UpdateAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static async Task CanDelete(DeleteAsset command, IAssetEntity asset, IContentRepository contentRepository)
{
Guard.NotNull(command, nameof(command));
@ -59,18 +51,5 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
}
}
}
private static async Task CheckPathAsync(DomainId appId, DomainId parentId, IAssetQueryService assetQuery, AddValidation e)
{
if (parentId != DomainId.Empty)
{
var path = await assetQuery.FindAssetFolderAsync(appId, parentId);
if (path.Count == 0)
{
e(T.Get("assets.folderNotFound"), nameof(MoveAsset.ParentId));
}
}
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/GuardAssetFolder.cs

@ -56,11 +56,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
});
}
public static void CanDelete(DeleteAssetFolder command)
{
Guard.NotNull(command, nameof(command));
}
private static async Task CheckPathAsync(DomainId appId, DomainId parentId, IAssetQueryService assetQuery, DomainId id, AddValidation e)
{
if (parentId != DomainId.Empty)

2
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -17,6 +17,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task<IResultList<IAssetFolderEntity>> QueryAssetFoldersAsync(Context context, DomainId parentId);
Task<IResultList<IAssetFolderEntity>> QueryAssetFoldersAsync(DomainId appId, DomainId parentId);
Task<IReadOnlyList<IAssetFolderEntity>> FindAssetFolderAsync(DomainId appId, DomainId id);
Task<IEnrichedAssetEntity?> FindByHashAsync(Context context, string hash, string fileName, long fileSize);

10
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs

@ -68,6 +68,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
}
}
public async Task<IResultList<IAssetFolderEntity>> QueryAssetFoldersAsync(DomainId appId, DomainId parentId)
{
using (Profiler.TraceMethod<AssetQueryService>())
{
var assetFolders = await assetFolderRepository.QueryAsync(appId, parentId);
return assetFolders;
}
}
public async Task<IResultList<IAssetFolderEntity>> QueryAssetFoldersAsync(Context context, DomainId parentId)
{
using (Profiler.TraceMethod<AssetQueryService>())

6
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentExtensions.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
@ -39,6 +40,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
cache.AddHeader(HeaderUnpublished);
}
public static Status EditingStatus(this IContentEntity content)
{
return content.NewStatus ?? content.Status;
}
public static SearchScope Scope(this Context context)
{
return context.ShouldProvideUnpublished() || context.IsFrontendClient ? SearchScope.All : SearchScope.Published;

6
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.State.cs

@ -40,12 +40,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
get => NewVersion?.Data ?? CurrentVersion.Data;
}
[IgnoreDataMember]
public Status EditingStatus
{
get => NewStatus ?? Status;
}
[IgnoreDataMember]
public Status? NewStatus
{

259
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs

@ -10,7 +10,6 @@ using System.Linq;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards;
using Squidex.Domain.Apps.Events;
@ -21,29 +20,25 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
using Squidex.Log;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
public sealed partial class ContentDomainObject : DomainObject<ContentDomainObject.State>
{
private readonly ContentOperationContext context;
private readonly IServiceProvider serviceProvider;
public ContentDomainObject(IStore<DomainId> store, ISemanticLog log,
ContentOperationContext context)
IServiceProvider serviceProvider)
: base(store, log)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(serviceProvider, nameof(serviceProvider));
this.context = context;
this.serviceProvider = serviceProvider;
Capacity = int.MaxValue;
}
private Task LoadContext(ContentCommand command, bool optimize)
{
return context.LoadAsync(command.AppId, command.SchemaId, command, optimize);
}
protected override bool IsDeleted()
{
return Snapshot.IsDeleted;
@ -74,20 +69,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case UpsertContent upsertContent:
return UpsertReturnAsync(upsertContent, async c =>
{
await LoadContext(c, c.OptimizeValidation);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
if (Version > EtagVersion.Empty && !IsDeleted())
{
await UpdateCore(c.AsUpdate(), x => c.Data, false);
await UpdateCore(c.AsUpdate(), operation);
}
else
{
await CreateCore(c.AsCreate());
await CreateCore(c.AsCreate(), operation);
}
if (Is.OptionalChange(Snapshot.EditingStatus, c.Status))
if (Is.OptionalChange(operation.Content.EditingStatus(), c.Status))
{
await ChangeCore(c.AsChange(c.Status.Value));
await ChangeCore(c.AsChange(c.Status.Value), operation);
}
return Snapshot;
@ -96,18 +91,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case CreateContent createContent:
return CreateReturnAsync(createContent, async c =>
{
await LoadContext(c, false);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await CreateCore(c);
await CreateCore(c, operation);
// Skip validation for singleton contents because it is published from command middleware.
if (context.Schema.SchemaDef.IsSingleton)
if (operation.Schema.SchemaDef.IsSingleton)
{
ChangeStatus(c.AsChange(Status.Published));
}
else if (Is.OptionalChange(Snapshot.EditingStatus, c.Status))
else if (Is.OptionalChange(Snapshot.Status, c.Status))
{
await ChangeCore(c.AsChange(c.Status.Value));
await ChangeCore(c.AsChange(c.Status.Value), operation);
}
return Snapshot;
@ -116,11 +110,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case ValidateContent validate:
return UpdateReturnAsync(validate, async c =>
{
await LoadContext(c, false);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
GuardContent.CanValidate(c, Snapshot);
operation.MustHavePermission(Permissions.AppContentsReadOwn);
await context.ValidateContentAndInputAsync(Snapshot.Data);
await operation.ValidateContentAndInputAsync(Snapshot.Data, false);
return true;
});
@ -128,11 +122,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case CreateContentDraft createDraft:
return UpdateReturnAsync(createDraft, async c =>
{
await LoadContext(c, false);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
GuardContent.CanCreateDraft(c, Snapshot);
operation.MustHavePermission(Permissions.AppContentsVersionCreate);
operation.MustCreateDraft();
var status = await context.GetInitialStatusAsync();
var status = await operation.GetInitialStatusAsync();
CreateDraft(c, status);
@ -142,9 +137,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case DeleteContentDraft deleteDraft:
return UpdateReturnAsync(deleteDraft, async c =>
{
await LoadContext(c, false);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
GuardContent.CanDeleteDraft(c, Snapshot);
operation.MustHavePermission(Permissions.AppContentsVersionDelete);
operation.MustDeleteDraft();
DeleteDraft(c);
@ -154,9 +150,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c =>
{
await LoadContext(c, c.OptimizeValidation);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await UpdateCore(c, c.Data.MergeInto, true);
await PatchCore(c, operation);
return Snapshot;
});
@ -164,9 +160,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c =>
{
await LoadContext(c, c.OptimizeValidation);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await UpdateCore(c, x => c.Data, false);
await UpdateCore(c, operation);
return Snapshot;
});
@ -176,15 +172,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
try
{
await LoadContext(c, c.OptimizeValidation);
if (c.DueTime > SystemClock.Instance.GetCurrentInstant())
{
ChangeStatusScheduled(c, c.DueTime.Value);
}
else
{
await ChangeCore(c);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await ChangeCore(c, operation);
}
}
catch (Exception)
@ -205,13 +201,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
case DeleteContent deleteContent when (deleteContent.Permanent):
return DeletePermanentAsync(deleteContent, async c =>
{
await DeleteCore(c);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation);
});
case DeleteContent deleteContent:
return UpdateAsync(deleteContent, async c =>
{
await DeleteCore(c);
var operation = await OperationContext.CreateAsync(serviceProvider, c, () => Snapshot);
await DeleteCore(c, operation);
});
default:
@ -219,158 +219,177 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
}
}
private async Task CreateCore(CreateContent c)
private async Task CreateCore(CreateContent c, OperationContext operation)
{
var status = await context.GetInitialStatusAsync();
GuardContent.CanCreate(c, context.Schema);
var dataNew = c.Data;
operation.MustNotCreateSingleton();
operation.MustHaveData(c.Data);
if (!c.DoNotValidate)
{
await context.ValidateInputAsync(dataNew);
await operation.ValidateInputAsync(c.Data, c.OptimizeValidation);
}
var status = await operation.GetInitialStatusAsync();
if (!c.DoNotScript)
{
dataNew = await context.ExecuteScriptAndTransformAsync(s => s.Create,
new ScriptVars
{
Operation = "Create",
Data = dataNew,
Status = status,
StatusOld = default
});
c.Data = await operation.ExecuteCreateScriptAsync(c.Data, status);
}
await context.GenerateDefaultValuesAsync(dataNew);
operation.GenerateDefaultValues(c.Data);
if (!c.DoNotValidate)
{
await context.ValidateContentAsync(dataNew);
await operation.ValidateContentAsync(c.Data, c.OptimizeValidation);
}
Create(c, dataNew, status);
Create(c, status);
}
private async Task ChangeCore(ChangeContentStatus c)
private async Task ChangeCore(ChangeContentStatus c, OperationContext operation)
{
await GuardContent.CanChangeStatus(c, Snapshot, context.Workflow, context.Repository, context.Schema);
operation.MustHavePermission(Permissions.AppContentsChangeStatusOwn);
operation.MustNotChangeSingleton(c.Status);
if (c.Status == Snapshot.EditingStatus)
if (c.Status == Snapshot.EditingStatus())
{
return;
}
// Check for script to skip cloning if no script configured.
if (!c.DoNotScript && context.HasScript(c => c.Change))
if (c.DoNotValidateWorkflow)
{
var change = GetChange(c.Status);
// Clone the data, so that we do not change it in cases of errors.
var data = Snapshot.Data.Clone();
await operation.CheckStatusAsync(c.Status);
}
else
{
await operation.CheckTransitionAsync(c.Status);
}
var newData = await context.ExecuteScriptAndTransformAsync(s => s.Change,
new ScriptVars
{
Operation = change.ToString(),
Data = data,
Status = c.Status,
StatusOld = Snapshot.EditingStatus
});
if (c.CheckReferrers && Snapshot.Status == Status.Published)
{
await operation.CheckReferrersAsync();
}
// Just update the previous data event to improve performance and add less events.
var previousEvent =
GetUncomittedEvents().Select(x => x.Payload)
.OfType<ContentDataCommand>().FirstOrDefault();
if (!c.DoNotScript)
{
var newData = await operation.ExecuteChangeScriptAsync(c.Status, GetChange(c.Status));
if (previousEvent != null)
{
previousEvent.Data = newData;
}
else if (!newData.Equals(Snapshot.Data))
if (!newData.Equals(Snapshot.Data))
{
Update(c, newData);
var previousEvent =
GetUncomittedEvents().Select(x => x.Payload)
.OfType<ContentDataCommand>().FirstOrDefault();
if (previousEvent != null)
{
previousEvent.Data = newData;
}
else if (!newData.Equals(Snapshot.Data))
{
Update(c, newData);
}
}
}
if (!c.DoNotValidate && c.Status == Status.Published)
if (!c.DoNotValidate && c.Status == Status.Published && operation.SchemaDef.Properties.ValidateOnPublish)
{
await context.ValidateOnPublishAsync(Snapshot.Data);
await operation.ValidateContentAndInputAsync(Snapshot.Data, c.OptimizeValidation);
}
ChangeStatus(c);
}
private async Task UpdateCore(UpdateContent c, Func<ContentData, ContentData> update, bool partial)
private async Task UpdateCore(UpdateContent c, OperationContext operation)
{
await GuardContent.CanUpdate(c, Snapshot, context.Workflow);
operation.MustHavePermission(Permissions.AppContentsUpdate);
operation.MustHaveData(c.Data);
if (!c.DoNotValidate)
{
await operation.ValidateInputPartialAsync(c.Data, c.OptimizeValidation);
}
var newData = update(Snapshot.Data);
if (!c.DoNotValidateWorkflow)
{
await operation.CheckUpdateAsync();
}
var newData = c.Data;
if (newData.Equals(Snapshot.Data))
{
return;
}
if (!c.DoNotScript)
{
newData = await operation.ExecuteUpdateScriptAsync(newData);
}
if (!c.DoNotValidate)
{
if (partial)
{
await context.ValidateInputPartialAsync(c.Data);
}
else
{
await context.ValidateInputAsync(c.Data);
}
await operation.ValidateContentAsync(newData, c.OptimizeValidation);
}
Update(c, newData);
}
private async Task PatchCore(UpdateContent c, OperationContext operation)
{
operation.MustHavePermission(Permissions.AppContentsUpdate);
operation.MustHaveData(c.Data);
if (!c.DoNotValidate)
{
await operation.ValidateInputPartialAsync(c.Data, c.OptimizeValidation);
}
if (!c.DoNotValidateWorkflow)
{
await operation.CheckUpdateAsync();
}
var newData = c.Data.MergeInto(Snapshot.Data);
if (newData.Equals(Snapshot.Data))
{
return;
}
if (!c.DoNotScript)
{
newData = await context.ExecuteScriptAndTransformAsync(s => s.Update,
new ScriptVars
{
Operation = "Update",
Data = newData,
DataOld = Snapshot.Data,
Status = Snapshot.EditingStatus,
StatusOld = default
});
newData = await operation.ExecuteUpdateScriptAsync(newData);
}
if (!c.DoNotValidate)
{
await context.ValidateContentAsync(newData);
await operation.ValidateContentAsync(newData, c.OptimizeValidation);
}
Update(c, newData);
}
private async Task DeleteCore(DeleteContent c)
private async Task DeleteCore(DeleteContent c, OperationContext operation)
{
await LoadContext(c, false);
operation.MustHavePermission(Permissions.AppContentsDeleteOwn);
operation.MustNotDeleteSingleton();
await GuardContent.CanDelete(c, Snapshot, context.Repository, context.Schema);
if (c.CheckReferrers)
{
await operation.CheckReferrersAsync();
}
if (!c.DoNotScript)
{
await context.ExecuteScriptAsync(s => s.Delete,
new ScriptVars
{
Operation = "Delete",
Data = Snapshot.Data,
Status = Snapshot.EditingStatus,
StatusOld = default
});
await operation.ExecuteDeleteScriptAsync();
}
Delete(c);
}
private void Create(CreateContent command, ContentData data, Status status)
private void Create(CreateContent command, Status status)
{
Raise(command, new ContentCreated { Data = data, Status = status });
Raise(command, new ContentCreated { Status = status });
}
private void Update(ContentCommand command, ContentData data)
@ -419,7 +438,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
return StatusChange.Published;
}
else if (Snapshot.EditingStatus == Status.Published)
else if (Snapshot.EditingStatus() == Status.Published)
{
return StatusChange.Unpublished;
}

235
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentOperationContext.cs

@ -1,235 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.DefaultValues;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Validation;
using Squidex.Log;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
public sealed class ContentOperationContext
{
private static readonly ScriptOptions ScriptOptions = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true
};
private readonly IScriptEngine scriptEngine;
private readonly ISemanticLog log;
private readonly IAppProvider appProvider;
private readonly IEnumerable<IValidatorsFactory> validators;
private readonly IContentWorkflow contentWorkflow;
private readonly IContentRepository contentRepository;
private readonly IJsonSerializer jsonSerializer;
private ISchemaEntity schema;
private IAppEntity app;
private ContentCommand command;
private ValidationContext validationContext;
public IContentWorkflow Workflow => contentWorkflow;
public IContentRepository Repository => contentRepository;
public ContentOperationContext(
IAppProvider appProvider,
IEnumerable<IValidatorsFactory> validators,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository,
IJsonSerializer jsonSerializer,
IScriptEngine scriptEngine,
ISemanticLog log)
{
Guard.NotDefault(appProvider, nameof(appProvider));
Guard.NotDefault(validators, nameof(validators));
Guard.NotDefault(contentWorkflow, nameof(contentWorkflow));
Guard.NotDefault(contentRepository, nameof(contentRepository));
Guard.NotDefault(jsonSerializer, nameof(jsonSerializer));
Guard.NotDefault(scriptEngine, nameof(scriptEngine));
Guard.NotDefault(log, nameof(log));
this.appProvider = appProvider;
this.validators = validators;
this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository;
this.jsonSerializer = jsonSerializer;
this.scriptEngine = scriptEngine;
this.log = log;
}
public ISchemaEntity Schema
{
get => schema;
}
public async Task LoadAsync(NamedId<DomainId> appId, NamedId<DomainId> schemaId, ContentCommand command, bool optimized)
{
this.command = command;
var (app, schema) = await appProvider.GetAppWithSchemaAsync(appId.Id, schemaId.Id);
if (app == null)
{
throw new DomainObjectNotFoundException(appId.ToString());
}
this.app = app;
if (schema == null)
{
throw new DomainObjectNotFoundException(schemaId.ToString());
}
this.schema = schema;
validationContext = new ValidationContext(jsonSerializer, appId, schemaId, schema.SchemaDef, command.ContentId).Optimized(optimized);
}
public Task<Status> GetInitialStatusAsync()
{
return contentWorkflow.GetInitialStatusAsync(schema);
}
public Task GenerateDefaultValuesAsync(ContentData data)
{
data.GenerateDefaultValues(schema.SchemaDef, Partition());
return Task.CompletedTask;
}
public async Task ValidateInputAsync(ContentData data)
{
var validator =
new ContentValidator(Partition(),
validationContext, validators, log);
await validator.ValidateInputAsync(data);
CheckErrors(validator);
}
public async Task ValidateInputPartialAsync(ContentData data)
{
var validator =
new ContentValidator(Partition(),
validationContext, validators, log);
await validator.ValidateInputPartialAsync(data);
CheckErrors(validator);
}
public async Task ValidateContentAsync(ContentData data)
{
var validator =
new ContentValidator(Partition(),
validationContext, validators, log);
await validator.ValidateContentAsync(data);
CheckErrors(validator);
}
public async Task ValidateContentAndInputAsync(ContentData data)
{
var validator =
new ContentValidator(Partition(),
validationContext.AsPublishing(), validators, log);
await validator.ValidateInputAsync(data);
await validator.ValidateContentAsync(data);
CheckErrors(validator);
}
public Task ValidateOnPublishAsync(ContentData data)
{
if (!schema.SchemaDef.Properties.ValidateOnPublish)
{
return Task.CompletedTask;
}
return ValidateContentAndInputAsync(data);
}
private static void CheckErrors(ContentValidator validator)
{
if (validator.Errors.Count > 0)
{
throw new ValidationException(validator.Errors.ToList());
}
}
public bool HasScript(Func<SchemaScripts, string> script)
{
return !string.IsNullOrWhiteSpace(GetScript(script));
}
public async Task<ContentData> ExecuteScriptAndTransformAsync(Func<SchemaScripts, string> script, ScriptVars context)
{
Enrich(context);
var actualScript = GetScript(script);
if (string.IsNullOrWhiteSpace(actualScript))
{
return context.Data!;
}
return await scriptEngine.TransformAsync(context, actualScript, ScriptOptions);
}
public async Task ExecuteScriptAsync(Func<SchemaScripts, string> script, ScriptVars context)
{
Enrich(context);
var actualScript = GetScript(script);
if (string.IsNullOrWhiteSpace(actualScript))
{
return;
}
await scriptEngine.ExecuteAsync(context, GetScript(script), ScriptOptions);
}
private PartitionResolver Partition()
{
return app.PartitionResolver();
}
private void Enrich(ScriptVars context)
{
context.ContentId = command.ContentId;
context.AppId = app.Id;
context.AppName = app.Name;
context.User = command.User;
}
private string GetScript(Func<SchemaScripts, string> script)
{
return script(schema.SchemaDef.Scripts);
}
}
}

201
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/GuardContent.cs

@ -1,201 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Shared;
using Squidex.Shared.Identity;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class GuardContent
{
public static void CanCreate(CreateContent command, ISchemaEntity schema)
{
Guard.NotNull(command, nameof(command));
if (schema.SchemaDef.IsSingleton)
{
if (command.ContentId != schema.Id)
{
throw new DomainException(T.Get("contents.singletonNotCreatable"));
}
}
Validate.It(e =>
{
if (command.Data == null)
{
e(Not.Defined(nameof(command.Data)), nameof(command.Data));
}
});
}
public static async Task CanUpdate(UpdateContent command, IContentEntity content, IContentWorkflow contentWorkflow)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsUpdate, Permissions.AppContentsUpsert);
Validate.It(e =>
{
if (command.Data == null)
{
e(Not.Defined(nameof(command.Data)), nameof(command.Data));
}
});
if (!command.DoNotValidateWorkflow)
{
var status = content.NewStatus ?? content.Status;
if (!await contentWorkflow.CanUpdateAsync(content, status, command.User))
{
throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status }));
}
}
}
public static void CanDeleteDraft(DeleteContentDraft command, IContentEntity content)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsVersionDelete);
if (content.NewStatus == null)
{
throw new DomainException(T.Get("contents.draftToDeleteNotFound"));
}
}
public static void CanCreateDraft(CreateContentDraft command, IContentEntity content)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsVersionCreate);
if (content.Status != Status.Published)
{
throw new DomainException(T.Get("contents.draftNotCreateForUnpublished"));
}
}
public static async Task CanChangeStatus(ChangeContentStatus command,
IContentEntity content,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository,
ISchemaEntity schema)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsChangeStatus, Permissions.AppContentsUpsert);
var newStatus = command.Status;
if (schema.SchemaDef.IsSingleton)
{
if (content.NewStatus == null || newStatus != Status.Published)
{
throw new DomainException(T.Get("contents.singletonNotChangeable"));
}
return;
}
var oldStatus = content.NewStatus ?? content.Status;
if (command.Status == oldStatus)
{
return;
}
if (oldStatus == Status.Published && command.CheckReferrers)
{
var hasReferrer = await contentRepository.HasReferrersAsync(content.AppId.Id, command.ContentId, SearchScope.Published);
if (hasReferrer)
{
throw new DomainException(T.Get("contents.referenced"));
}
}
await Validate.It(async e =>
{
if (!command.DoNotValidateWorkflow)
{
if (!await contentWorkflow.CanMoveToAsync(content, oldStatus, newStatus, command.User))
{
var values = new { oldStatus, newStatus };
e(T.Get("contents.statusTransitionNotAllowed", values), "Status");
}
}
else
{
var info = await contentWorkflow.GetInfoAsync(content, newStatus);
if (info == null)
{
e(T.Get("contents.statusNotValid"), "Status");
}
}
});
}
public static async Task CanDelete(DeleteContent command,
IContentEntity content,
IContentRepository contentRepository,
ISchemaEntity schema)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsDeleteOwn);
if (schema.SchemaDef.IsSingleton)
{
throw new DomainException(T.Get("contents.singletonNotDeletable"));
}
if (command.CheckReferrers)
{
var hasReferrer = await contentRepository.HasReferrersAsync(content.AppId.Id, content.Id, SearchScope.All);
if (hasReferrer)
{
throw new DomainException(T.Get("contents.referenced"));
}
}
}
public static void CanValidate(ValidateContent command, IContentEntity content)
{
Guard.NotNull(command, nameof(command));
CheckPermission(content, command, Permissions.AppContentsRead);
}
public static void CheckPermission(IContentEntity content, ContentCommand command, params string[] permissions)
{
if (Equals(content.CreatedBy, command.Actor) || command.User == null)
{
return;
}
if (permissions.All(x => !command.User.Allows(x, content.AppId.Name, content.SchemaId.Name)))
{
throw new DomainForbiddenException(T.Get("common.errorNoPermission"));
}
}
}
}

122
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ScriptingExtensions.cs

@ -0,0 +1,122 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class ScriptingExtensions
{
private static readonly ScriptOptions Options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true
};
public static async Task<ContentData> ExecuteCreateScriptAsync(this OperationContext context, ContentData data, Status status)
{
var script = context.SchemaDef.Scripts.Create;
if (!string.IsNullOrWhiteSpace(script))
{
var vars = Enrich(context, new ScriptVars
{
Operation = "Create",
Data = data,
DataOld = default,
Status = status,
StatusOld = default
});
data = await GetScriptEngine(context).TransformAsync(vars, script, Options);
}
return data;
}
public static async Task<ContentData> ExecuteUpdateScriptAsync(this OperationContext context, ContentData data)
{
var script = context.SchemaDef.Scripts.Update;
if (!string.IsNullOrWhiteSpace(script))
{
var vars = Enrich(context, new ScriptVars
{
Operation = "Update",
Data = data,
DataOld = context.Content.Data,
Status = context.Content.EditingStatus(),
StatusOld = default
});
data = await GetScriptEngine(context).TransformAsync(vars, script, Options);
}
return data;
}
public static async Task<ContentData> ExecuteChangeScriptAsync(this OperationContext context, Status status, StatusChange change)
{
var script = context.SchemaDef.Scripts.Change;
if (!string.IsNullOrWhiteSpace(script))
{
var data = context.Content.Data.Clone();
var vars = Enrich(context, new ScriptVars
{
Operation = change.ToString(),
Data = data,
DataOld = default,
Status = status,
StatusOld = context.Content.EditingStatus()
});
return await GetScriptEngine(context).TransformAsync(vars, script, Options);
}
return context.Content.Data;
}
public static async Task ExecuteDeleteScriptAsync(this OperationContext context)
{
var script = context.SchemaDef.Scripts.Delete;
if (!string.IsNullOrWhiteSpace(script))
{
var vars = Enrich(context, new ScriptVars
{
Operation = "Delete",
Data = context.Content.Data,
DataOld = default,
Status = context.Content.EditingStatus(),
StatusOld = default
});
await GetScriptEngine(context).ExecuteAsync(vars, script, Options);
}
}
private static IScriptEngine GetScriptEngine(OperationContext context)
{
return context.Resolve<IScriptEngine>();
}
private static ScriptVars Enrich(OperationContext context, ScriptVars vars)
{
vars.ContentId = context.ContentId;
vars.AppId = context.App.Id;
vars.AppName = context.App.Name;
vars.User = context.User;
return vars;
}
}
}

31
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Shared.Identity;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class SecurityExtensions
{
public static void MustHavePermission(this OperationContext context, string permissionId)
{
var content = context.Content;
if (Equals(content.CreatedBy, context.Actor) || context.User == null)
{
return;
}
if (!context.User.Allows(permissionId, content.AppId.Name, content.SchemaId.Name))
{
throw new DomainForbiddenException(T.Get("common.errorNoPermission"));
}
}
}
}

40
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SingletonExtensions.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class SingletonExtensions
{
public static void MustNotCreateSingleton(this OperationContext context)
{
if (context.SchemaDef.IsSingleton && context.ContentId != context.Schema.Id)
{
throw new DomainException(T.Get("contents.singletonNotCreatable"));
}
}
public static void MustNotChangeSingleton(this OperationContext context, Status status)
{
if (context.SchemaDef.IsSingleton && (context.Content.NewStatus == null || status != Status.Published))
{
throw new DomainException(T.Get("contents.singletonNotChangeable"));
}
}
public static void MustNotDeleteSingleton(this OperationContext context)
{
if (context.SchemaDef.IsSingleton)
{
throw new DomainException(T.Get("contents.singletonNotDeletable"));
}
}
}
}

129
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/ValidationExtensions.cs

@ -0,0 +1,129 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.DefaultValues;
using Squidex.Domain.Apps.Core.ValidateContent;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
using Squidex.Log;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class ValidationExtensions
{
public static void MustDeleteDraft(this OperationContext context)
{
if (context.Content.NewStatus == null)
{
throw new DomainException(T.Get("contents.draftToDeleteNotFound"));
}
}
public static void MustCreateDraft(this OperationContext context)
{
if (context.Content.EditingStatus() != Status.Published)
{
throw new DomainException(T.Get("contents.draftNotCreateForUnpublished"));
}
}
public static void MustHaveData(this OperationContext context, ContentData? data)
{
if (data == null)
{
context.AddError(Not.Defined(nameof(data)), nameof(data)).ThrowOnErrors();
}
}
public static async Task ValidateInputAsync(this OperationContext context, ContentData data, bool optimize)
{
var validator = GetValidator(context, optimize);
await validator.ValidateInputAsync(data);
context.AddErrors(validator.Errors).ThrowOnErrors();
}
public static async Task ValidateInputPartialAsync(this OperationContext context, ContentData data, bool optimize)
{
var validator = GetValidator(context, optimize);
await validator.ValidateInputPartialAsync(data);
context.AddErrors(validator.Errors).ThrowOnErrors();
}
public static async Task ValidateContentAsync(this OperationContext context, ContentData data, bool optimize)
{
var validator = GetValidator(context, optimize);
await validator.ValidateContentAsync(data);
context.AddErrors(validator.Errors).ThrowOnErrors();
}
public static async Task ValidateContentAndInputAsync(this OperationContext operation, ContentData data, bool optimize)
{
var validator = GetValidator(operation, optimize);
await validator.ValidateInputAsync(data);
await validator.ValidateContentAsync(data);
operation.AddErrors(validator.Errors).ThrowOnErrors();
}
public static void GenerateDefaultValues(this OperationContext context, ContentData data)
{
data.GenerateDefaultValues(context.Schema.SchemaDef, context.Partition());
}
public static async Task CheckReferrersAsync(this OperationContext context)
{
var contentRepository = context.Resolve<IContentRepository>();
var hasReferrer = await contentRepository.HasReferrersAsync(context.App.Id, context.ContentId, SearchScope.All);
if (hasReferrer)
{
throw new DomainException(T.Get("contents.referenced"));
}
}
private static ContentValidator GetValidator(this OperationContext context, bool optimize)
{
var validationContext =
new ValidationContext(context.Resolve<IJsonSerializer>(),
context.App.NamedId(),
context.Schema.NamedId(),
context.SchemaDef,
context.ContentId)
.Optimized(optimize);
var validator =
new ContentValidator(context.Partition(),
validationContext,
context.Resolve<IEnumerable<IValidatorsFactory>>(),
context.Resolve<ISemanticLog>());
return validator;
}
private static PartitionResolver Partition(this OperationContext context)
{
return context.App.PartitionResolver();
}
}
}

78
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs

@ -0,0 +1,78 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public static class WorkflowExtensions
{
public static Task<Status> GetInitialStatusAsync(this OperationContext context)
{
var workflow = GetWorkflow(context);
return workflow.GetInitialStatusAsync(context.Schema);
}
public static async Task CheckTransitionAsync(this OperationContext context, Status status)
{
if (!context.SchemaDef.IsSingleton)
{
var workflow = GetWorkflow(context);
var oldStatus = context.Content.EditingStatus();
if (!await workflow.CanMoveToAsync(context.Content, oldStatus, status, context.User))
{
var values = new { oldStatus, newStatus = status };
context.AddError(T.Get("contents.statusTransitionNotAllowed", values), nameof(status));
context.ThrowOnErrors();
}
}
}
public static async Task CheckStatusAsync(this OperationContext context, Status status)
{
if (!context.SchemaDef.IsSingleton)
{
var workflow = GetWorkflow(context);
var statusInfo = await workflow.GetInfoAsync(context.Content, status);
if (statusInfo == null)
{
context.AddError(T.Get("contents.statusNotValid"), nameof(status));
context.ThrowOnErrors();
}
}
}
public static async Task CheckUpdateAsync(this OperationContext context)
{
if (context.User != null)
{
var workflow = GetWorkflow(context);
var status = context.Content.EditingStatus();
if (!await workflow.CanUpdateAsync(context.Content, status, context.User))
{
throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status }));
}
}
}
private static IContentWorkflow GetWorkflow(OperationContext context)
{
return context.Resolve<IContentWorkflow>();
}
}
}

122
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/OperationContext.cs

@ -0,0 +1,122 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using GraphQL.Utilities;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
{
public sealed class OperationContext
{
private readonly List<ValidationError> errors = new List<ValidationError>();
private readonly IServiceProvider serviceProvider;
public ClaimsPrincipal? User { get; init; }
public RefToken Actor { get; init; }
public IAppEntity App { get; init; }
public ISchemaEntity Schema { get; init; }
public DomainId ContentId { get; init; }
public Func<IContentEntity> ContentProvider { get; init; }
public IContentEntity Content
{
get => ContentProvider();
}
public Schema SchemaDef
{
get => Schema.SchemaDef;
}
public OperationContext(IServiceProvider serviceProvider)
{
Guard.NotNull(serviceProvider, nameof(serviceProvider));
this.serviceProvider = serviceProvider;
}
public static async Task<OperationContext> CreateAsync(IServiceProvider services, ContentCommand command, Func<IContentEntity> snapshot)
{
var appProvider = services.GetRequiredService<IAppProvider>();
var (app, schema) = await appProvider.GetAppWithSchemaAsync(command.AppId.Id, command.SchemaId.Id);
if (app == null)
{
throw new DomainObjectNotFoundException(command.AppId.Id.ToString());
}
if (schema == null)
{
throw new DomainObjectNotFoundException(command.SchemaId.Id.ToString());
}
return new OperationContext(services)
{
App = app,
Actor = command.Actor,
ContentProvider = snapshot,
ContentId = command.ContentId,
Schema = schema,
User = command.User
};
}
public T Resolve<T>()
{
return serviceProvider.GetRequiredService<T>();
}
public T? ResolveOptional<T>() where T : class
{
return serviceProvider.GetService(typeof(T)) as T;
}
public OperationContext AddError(string message, params string[] propertyNames)
{
errors.Add(new ValidationError(message, propertyNames));
return this;
}
public OperationContext AddError(ValidationError newError)
{
errors.Add(newError);
return this;
}
public OperationContext AddErrors(IEnumerable<ValidationError> newErrors)
{
errors.AddRange(newErrors);
return this;
}
public void ThrowOnErrors()
{
if (errors.Count > 0)
{
throw new ValidationException(errors);
}
}
}
}

15
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs

@ -65,20 +65,5 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards
}
});
}
public static void CanEnable(EnableRule command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanDisable(DisableRule command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanDelete(DeleteRule command)
{
Guard.NotNull(command, nameof(command));
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs

@ -80,8 +80,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
case EnableRule enable:
return UpdateReturn(enable, c =>
{
GuardRule.CanEnable(c);
Enable(c);
return Snapshot;
@ -90,8 +88,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
case DisableRule disable:
return UpdateReturn(disable, c =>
{
GuardRule.CanDisable(c);
Disable(c);
return Snapshot;
@ -100,8 +96,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
case DeleteRule delete:
return Update(delete, c =>
{
GuardRule.CanDelete(delete);
Delete(c);
});

30
backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/Guards/GuardSchema.cs

@ -108,36 +108,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
});
}
public static void CanPublish(PublishSchema command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUnpublish(UnpublishSchema command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUpdate(UpdateSchema command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanConfigureScripts(ConfigureScripts command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanChangeCategory(ChangeCategory command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanDelete(DeleteSchema command)
{
Guard.NotNull(command, nameof(command));
}
private static void ValidateUpsert(IUpsertCommand command, AddValidation e)
{
if (command.Fields?.Length > 0)

66
backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs

@ -161,82 +161,72 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
return Snapshot;
});
case UpdateSchema update:
return UpdateReturn(update, c =>
case ConfigureFieldRules configureFieldRules:
return UpdateReturn(configureFieldRules, c =>
{
GuardSchema.CanUpdate(c);
GuardSchema.CanConfigureFieldRules(c);
Update(c);
ConfigureFieldRules(c);
return Snapshot;
});
case PublishSchema publish:
return UpdateReturn(publish, c =>
case ConfigurePreviewUrls configurePreviewUrls:
return UpdateReturn(configurePreviewUrls, c =>
{
GuardSchema.CanPublish(c);
GuardSchema.CanConfigurePreviewUrls(c);
Publish(c);
ConfigurePreviewUrls(c);
return Snapshot;
});
case UnpublishSchema unpublish:
return UpdateReturn(unpublish, c =>
case ConfigureUIFields configureUIFields:
return UpdateReturn(configureUIFields, c =>
{
GuardSchema.CanUnpublish(c);
GuardSchema.CanConfigureUIFields(c, Snapshot.SchemaDef);
Unpublish(c);
ConfigureUIFields(c);
return Snapshot;
});
case ConfigureFieldRules configureFieldRules:
return UpdateReturn(configureFieldRules, c =>
case ChangeCategory changeCategory:
return UpdateReturn(changeCategory, c =>
{
GuardSchema.CanConfigureFieldRules(c);
ConfigureFieldRules(c);
ChangeCategory(c);
return Snapshot;
});
case ConfigureScripts configureScripts:
return UpdateReturn(configureScripts, c =>
case UpdateSchema update:
return UpdateReturn(update, c =>
{
GuardSchema.CanConfigureScripts(c);
ConfigureScripts(c);
Update(c);
return Snapshot;
});
case ChangeCategory changeCategory:
return UpdateReturn(changeCategory, c =>
case PublishSchema publish:
return UpdateReturn(publish, c =>
{
GuardSchema.CanChangeCategory(c);
ChangeCategory(c);
Publish(c);
return Snapshot;
});
case ConfigurePreviewUrls configurePreviewUrls:
return UpdateReturn(configurePreviewUrls, c =>
case UnpublishSchema unpublish:
return UpdateReturn(unpublish, c =>
{
GuardSchema.CanConfigurePreviewUrls(c);
ConfigurePreviewUrls(c);
Unpublish(c);
return Snapshot;
});
case ConfigureUIFields configureUIFields:
return UpdateReturn(configureUIFields, c =>
case ConfigureScripts configureScripts:
return UpdateReturn(configureScripts, c =>
{
GuardSchema.CanConfigureUIFields(c, Snapshot.SchemaDef);
ConfigureUIFields(c);
ConfigureScripts(c);
return Snapshot;
});
@ -244,8 +234,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
case DeleteSchema deleteSchema:
return Update(deleteSchema, c =>
{
GuardSchema.CanDelete(c);
Delete(c);
});

4
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs

@ -115,7 +115,9 @@ namespace Squidex.Infrastructure.MongoDb
}
catch (Exception ex)
{
var error = new ConfigurationError($"MongoDb connection failed to connect to database {Database.DatabaseNamespace.DatabaseName}.");
var databaseName = Database.DatabaseNamespace.DatabaseName;
var error = new ConfigurationError($"MongoDb connection failed to connect to database {databaseName}.");
throw new ConfigurationException(error, ex);
}

8
backend/src/Squidex.Infrastructure/Commands/DomainObject.cs

@ -29,14 +29,14 @@ namespace Squidex.Infrastructure.Commands
get => uniqueId;
}
public long Version
public T Snapshot
{
get => snapshots.Version;
get => snapshots.Current;
}
public T Snapshot
public long Version
{
get => snapshots.Current;
get => snapshots.Version;
}
protected int Capacity

5
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsDto.cs

@ -25,6 +25,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public bool CheckReferrers { get; set; }
/// <summary>
/// True to turn off costly validation: Folder checks. Default: true.
/// </summary>
public bool OptimizeValidation { get; set; } = true;
public BulkUpdateAssets ToCommand()
{
var result = SimpleMapper.Map(this, new BulkUpdateAssets());

5
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs

@ -30,11 +30,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public DomainId ParentId { get; set; }
/// <summary>
/// The optional path to the folder.
/// </summary>
public string? ParentPath { get; set; }
/// <summary>
/// The new name of the asset.
/// </summary>

6
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs

@ -27,12 +27,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[FromQuery]
public DomainId ParentId { get; set; }
/// <summary>
/// The optional path to the parent folder.
/// </summary>
[FromQuery]
public string? ParentPath { get; set; }
/// <summary>
/// The optional custom asset id.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/MoveAssetDto.cs

@ -18,11 +18,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public DomainId ParentId { get; set; }
/// <summary>
/// The optional path to the folder.
/// </summary>
public string? ParentPath { get; set; }
public MoveAsset ToCommand(DomainId id)
{
return SimpleMapper.Map(this, new MoveAsset { AssetId = id });

6
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs

@ -27,12 +27,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[FromQuery]
public DomainId ParentId { get; set; }
/// <summary>
/// The optional path to the parent folder.
/// </summary>
[FromQuery]
public string? ParentPath { get; set; }
/// <summary>
/// True to duplicate the asset, event if the file has been uploaded.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/AssetsFieldPropertiesDto.cs

@ -29,6 +29,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields
/// </summary>
public string[]? DefaultValue { get; set; }
/// <summary>
/// The initial id to the folder.
/// </summary>
public string? FolderId { get; set; }
/// <summary>
/// The minimum allowed items for the field value.
/// </summary>

5
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/StringFieldPropertiesDto.cs

@ -33,6 +33,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields
/// </summary>
public string? PatternMessage { get; set; }
/// <summary>
/// The initial id to the folder when the control supports file uploads.
/// </summary>
public string? FolderId { get; set; }
/// <summary>
/// The minimum allowed length for the field value.
/// </summary>

3
backend/src/Squidex/Config/Domain/AssetServices.cs

@ -71,9 +71,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetQueryService>()
.As<IAssetQueryService>();
services.AddSingletonAs<AssetFolderResolver>()
.As<IAssetFolderResolver>();
services.AddSingletonAs<AssetLoader>()
.As<IAssetLoader>();

3
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -39,9 +39,6 @@ namespace Squidex.Config.Domain
services.AddTransientAs<ContentDomainObject>()
.AsSelf();
services.AddTransientAs<ContentOperationContext>()
.AsSelf();
services.AddSingletonAs<DefaultValidatorsFactory>()
.As<IValidatorsFactory>();

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/GenerateJsonSchema/JsonSchemaTests.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using NJsonSchema;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.GenerateJsonSchema;

47
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs

@ -23,7 +23,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
private readonly IAssetEnricher assetEnricher = A.Fake<IAssetEnricher>();
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetFolderResolver assetFolderResolver = A.Fake<IAssetFolderResolver>();
private readonly IAssetMetadataSource assetMetadataSource = A.Fake<IAssetMetadataSource>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
@ -57,7 +56,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
sut = new AssetCommandMiddleware(grainFactory,
assetEnricher,
assetFileStore,
assetFolderResolver,
assetQuery,
contextProvider, new[] { assetMetadataSource });
}
@ -126,21 +124,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Assert.True(command.FileHash.Length > 10);
}
[Fact]
public async Task Create_should_resolve_path()
{
var folderId = DomainId.NewGuid();
var command = new CreateAsset { File = file, ParentPath = "path/to/folder" };
A.CallTo(() => assetFolderResolver.ResolveOrCreateAsync(requestContext, A<ICommandBus>._, "path/to/folder"))
.Returns(folderId);
await HandleAsync(command, CreateAsset());
Assert.Equal(folderId, command.ParentId);
}
[Fact]
public async Task Create_should_not_return_duplicate_result_if_file_with_same_hash_found_but_duplicate_allowed()
{
@ -205,36 +188,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Assert.True(command.FileHash.Length > 10);
}
[Fact]
public async Task Upsert_should_resolve_path()
{
var folderId = DomainId.NewGuid();
var command = new UpsertAsset { File = file, ParentPath = "path/to/folder" };
A.CallTo(() => assetFolderResolver.ResolveOrCreateAsync(requestContext, A<ICommandBus>._, "path/to/folder"))
.Returns(folderId);
await HandleAsync(command, CreateAsset());
Assert.Equal(folderId, command.ParentId);
}
[Fact]
public async Task Move_should_resolve_path()
{
var folderId = DomainId.NewGuid();
var command = new MoveAsset { ParentPath = "path/to/folder" };
A.CallTo(() => assetFolderResolver.ResolveOrCreateAsync(requestContext, A<ICommandBus>._, "path/to/folder"))
.Returns(folderId);
await HandleAsync(command, CreateAsset());
Assert.Equal(folderId, command.ParentId);
}
private void AssertAssetHasBeenUploaded(long fileVersion)
{
A.CallTo(() => assetFileStore.UploadAsync(A<string>._, A<HasherStream>._, CancellationToken.None))

165
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetFolderResolverTests.cs

@ -1,165 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Caching;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
public class AssetFolderResolverTests
{
private readonly ILocalCache localCache = new AsyncLocalCache();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly Context requestContext;
private readonly AssetFolderResolver sut;
public AssetFolderResolverTests()
{
requestContext = Context.Anonymous(Mocks.App(appId));
localCache.StartContext();
sut = new AssetFolderResolver(localCache, assetQuery);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("/")]
[InlineData("\\")]
public async Task Should_resolve_root_id_for_empty_path(string path)
{
var folderId = await sut.ResolveOrCreateAsync(requestContext, commandBus, path);
Assert.Equal(DomainId.Empty, folderId);
}
[Fact]
public async Task Should_create_and_cache_level1_folder()
{
var folderId11_1 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1");
var folderId11_2 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1");
Assert.NotEqual(DomainId.Empty, folderId11_1);
Assert.NotEqual(DomainId.Empty, folderId11_2);
Assert.Equal(folderId11_2, folderId11_1);
A.CallTo(() => commandBus.PublishAsync(A<CreateAssetFolder>.That.Matches(x => x.FolderName == "level1")))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_create_and_cache_recursively()
{
var folderId21_1 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1/level2");
var folderId21_2 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1/level2");
Assert.NotEqual(DomainId.Empty, folderId21_1);
Assert.NotEqual(DomainId.Empty, folderId21_2);
Assert.Equal(folderId21_1, folderId21_2);
A.CallTo(() => commandBus.PublishAsync(A<CreateAssetFolder>.That.Matches(x => x.FolderName == "level1")))
.MustHaveHappenedOnceExactly();
A.CallTo(() => commandBus.PublishAsync(A<CreateAssetFolder>.That.Matches(x => x.FolderName == "level2")))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_cache_folders_on_same_level()
{
var folder11 = CreateFolder("level1_1");
var folder12 = CreateFolder("level1_2");
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, DomainId.Empty))
.Returns(ResultList.CreateFrom(2, folder11, folder12));
var folderId11 = await sut.ResolveOrCreateAsync(requestContext, commandBus, folder11.FolderName);
var folderId12 = await sut.ResolveOrCreateAsync(requestContext, commandBus, folder12.FolderName);
Assert.Equal(folder11.Id, folderId11);
Assert.Equal(folder12.Id, folderId12);
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, A<DomainId>._))
.MustHaveHappenedOnceExactly();
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_resolve_recursively()
{
var folder11 = CreateFolder("level1");
var folder21 = CreateFolder("level2");
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, DomainId.Empty))
.Returns(ResultList.CreateFrom(1, folder11));
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, folder11.Id))
.Returns(ResultList.CreateFrom(1, folder21));
var folderId2 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1/level2");
Assert.Equal(folder21.Id, folderId2);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_resolve_recursively_and_create_folder()
{
var folder11 = CreateFolder("level1");
var folder21 = CreateFolder("level2");
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, DomainId.Empty))
.Returns(ResultList.CreateFrom(1, folder11));
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, folder11.Id))
.Returns(ResultList.CreateFrom(1, folder21));
await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1/level2");
var folderId3 = await sut.ResolveOrCreateAsync(requestContext, commandBus, "level1/level2/level3");
Assert.NotEqual(DomainId.Empty, folderId3);
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, DomainId.Empty))
.MustHaveHappenedOnceExactly();
A.CallTo(() => assetQuery.QueryAssetFoldersAsync(requestContext, folder11.Id))
.MustHaveHappenedOnceExactly();
A.CallTo(() => commandBus.PublishAsync(A<CreateAssetFolder>.That.Matches(x => x.FolderName == "level3" && x.ParentId == folder21.Id)))
.MustHaveHappenedOnceExactly();
}
private static IAssetFolderEntity CreateFolder(string name)
{
var assetFolder = A.Fake<IAssetFolderEntity>();
A.CallTo(() => assetFolder.FolderName)
.Returns(name);
A.CallTo(() => assetFolder.Id)
.Returns(DomainId.NewGuid());
return assetFolder;
}
}
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs

@ -94,14 +94,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
var id = DomainId.NewGuid();
var command = BulkCommand(BulkUpdateAssetType.Move, id, parentPath: "/path/to/folder");
var command = BulkCommand(BulkUpdateAssetType.Move, id);
var result = await PublishAsync(command);
Assert.Single(result);
Assert.Single(result, x => x.JobIndex == 0 && x.Id == id && x.Exception == null);
A.CallTo(() => commandBus.PublishAsync(A<MoveAsset>.That.Matches(x => x.AssetId == id && x.ParentPath == "/path/to/folder")))
A.CallTo(() => commandBus.PublishAsync(A<MoveAsset>.That.Matches(x => x.AssetId == id)))
.MustHaveHappened();
}
@ -169,8 +169,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
return (context.PlainResult as BulkUpdateResult)!;
}
private BulkUpdateAssets BulkCommand(BulkUpdateAssetType type, DomainId id,
string? parentPath = null, string? fileName = null)
private BulkUpdateAssets BulkCommand(BulkUpdateAssetType type, DomainId id, string? fileName = null)
{
return new BulkUpdateAssets
{
@ -181,7 +180,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
{
Type = type,
Id = id,
ParentPath = parentPath,
FileName = fileName
}
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/Guards/GuardAssetFolderTests.cs

@ -139,14 +139,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
GuardAssetFolder.CanRename(command);
}
[Fact]
public void CanDelete_should_not_throw_exception()
{
var command = new DeleteAssetFolder { AppId = appId };
GuardAssetFolder.CanDelete(command);
}
private IAssetFolderEntity AssetFolder(DomainId id = default, DomainId parentId = default)
{
var assetFolder = A.Fake<IAssetFolderEntity>();

57
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/Guards/GuardAssetTests.cs

@ -25,20 +25,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
[Fact]
public void CanCreate_should_not_throw_exception_when_added_to_root()
{
var command = new CreateAsset { AppId = appId };
GuardAsset.CanCreate(command);
}
[Fact]
public async Task CanMove_should_throw_exception_when_folder_not_found()
{
var command = new MoveAsset { AppId = appId, ParentId = DomainId.NewGuid() };
var parentId = DomainId.NewGuid();
A.CallTo(() => assetQuery.FindAssetFolderAsync(appId.Id, command.ParentId))
var command = new MoveAsset { AppId = appId, ParentId = parentId };
A.CallTo(() => assetQuery.FindAssetFolderAsync(appId.Id, parentId))
.Returns(new List<IAssetFolderEntity>());
await ValidationAssert.ThrowsAsync(() => GuardAsset.CanMove(command, Asset(), assetQuery),
@ -46,54 +40,47 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards
}
[Fact]
public async Task CanMove_should_not_throw_exception_when_folder_has_not_changed()
public async Task CanMove_should_not_throw_exception_when_folder_not_found_but_optimized()
{
var command = new MoveAsset { AppId = appId, ParentId = DomainId.NewGuid() };
var parentId = DomainId.NewGuid();
await GuardAsset.CanMove(command, Asset(parentId: command.ParentId), assetQuery);
}
[Fact]
public async Task CanMove_should_not_throw_exception_when_folder_found()
{
var command = new MoveAsset { AppId = appId, ParentId = DomainId.NewGuid() };
var command = new MoveAsset { AppId = appId, ParentId = parentId, OptimizeValidation = true };
A.CallTo(() => assetQuery.FindAssetFolderAsync(appId.Id, command.ParentId))
.Returns(new List<IAssetFolderEntity> { AssetFolder() });
.Returns(new List<IAssetFolderEntity>());
await GuardAsset.CanMove(command, Asset(), assetQuery);
}
[Fact]
public async Task CanMove_should_not_throw_exception_when_added_to_root()
public async Task CanMove_should_not_throw_exception_when_folder_found()
{
var command = new MoveAsset { AppId = appId };
var parentId = DomainId.NewGuid();
await GuardAsset.CanMove(command, Asset(), assetQuery);
}
var command = new MoveAsset { AppId = appId, ParentId = parentId };
[Fact]
public void CanAnnotate_should_not_throw_exception_if_empty()
{
var command = new AnnotateAsset { AppId = appId };
A.CallTo(() => assetQuery.FindAssetFolderAsync(appId.Id, command.ParentId))
.Returns(new List<IAssetFolderEntity> { AssetFolder() });
GuardAsset.CanAnnotate(command);
await GuardAsset.CanMove(command, Asset(), assetQuery);
}
[Fact]
public void CanAnnotate_should_not_throw_exception_if_a_value_is_passed()
public async Task CanMove_should_not_throw_exception_when_folder_has_not_changed()
{
var command = new AnnotateAsset { AppId = appId, FileName = "new-name", Slug = "new-slug" };
var parentId = DomainId.NewGuid();
var command = new MoveAsset { AppId = appId, ParentId = parentId };
GuardAsset.CanAnnotate(command);
await GuardAsset.CanMove(command, Asset(parentId: parentId), assetQuery);
}
[Fact]
public void CanUpdate_should_not_throw_exception()
public async Task CanMove_should_not_throw_exception_when_added_to_root()
{
var command = new UpdateAsset { AppId = appId };
var command = new MoveAsset { AppId = appId };
GuardAsset.CanUpdate(command);
await GuardAsset.CanMove(command, Asset(), assetQuery);
}
[Fact]

15
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs

@ -206,6 +206,21 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
Assert.Same(assetFolders, result);
}
[Fact]
public async Task Should_query_asset_folders_with_appId()
{
var parentId = DomainId.NewGuid();
var assetFolders = ResultList.CreateFrom<IAssetFolderEntity>(10);
A.CallTo(() => assetFolderRepository.QueryAsync(appId.Id, parentId))
.Returns(assetFolders);
var result = await sut.QueryAssetFoldersAsync(appId.Id, parentId);
Assert.Same(assetFolders, result);
}
[Fact]
public async Task Should_find_asset_folder_with_path()
{

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs

@ -8,6 +8,7 @@
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
@ -105,17 +106,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
patched = patch.MergeInto(data);
var validators = Enumerable.Repeat(new DefaultValidatorsFactory(), 1);
var log = A.Fake<ISemanticLog>();
var context = new ContentOperationContext(
appProvider,
validators,
contentWorkflow,
contentRepository,
TestUtils.DefaultSerializer,
scriptEngine, A.Fake<ISemanticLog>());
var serviceProvider =
new ServiceCollection()
.AddSingleton(appProvider)
.AddSingleton(log)
.AddSingleton(contentWorkflow)
.AddSingleton(contentRepository)
.AddSingleton(scriptEngine)
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<IValidatorsFactory>(new DefaultValidatorsFactory())
.BuildServiceProvider();
sut = new ContentDomainObject(Store, A.Dummy<ISemanticLog>(), context);
sut = new ContentDomainObject(Store, log, serviceProvider);
sut.Setup(Id);
}
@ -770,7 +774,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
await ExecuteCreateAsync();
await ExecuteChangeStatusAsync(Status.Published);
A.CallTo(() => contentRepository.HasReferrersAsync(AppId, contentId, SearchScope.Published))
A.CallTo(() => contentRepository.HasReferrersAsync(AppId, contentId, SearchScope.All))
.Returns(true);
await Assert.ThrowsAsync<DomainException>(() => PublishAsync(command));

309
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs

@ -8,10 +8,10 @@
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.TestHelpers;
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.TestHelpers;
@ -24,348 +24,323 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
{
public class GuardContentTests : IClassFixture<TranslationsFixture>
{
private readonly IContentWorkflow workflow = A.Fake<IContentWorkflow>();
private readonly IContentWorkflow contentWorkflow = A.Fake<IContentWorkflow>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
private readonly ISchemaEntity schema;
private readonly ISchemaEntity singleton;
private readonly ISchemaEntity normalSchema;
private readonly ISchemaEntity singletonSchema;
private readonly ClaimsPrincipal user = Mocks.FrontendUser();
private readonly RefToken actor = RefToken.User("123");
public GuardContentTests()
{
schema =
normalSchema =
Mocks.Schema(appId, schemaId, new Schema(schemaId.Name));
singleton =
singletonSchema =
Mocks.Schema(appId, schemaId, new Schema(schemaId.Name, isSingleton: true));
}
[Fact]
public void CanCreate_should_throw_exception_if_data_is_null()
public void Should_throw_exception_if_creating_singleton_content()
{
var command = new CreateContent();
var context = CreateContext(CreateContent(Status.Draft), singletonSchema);
ValidationAssert.Throws(() => GuardContent.CanCreate(command, schema),
new ValidationError("Data is required.", "Data"));
Assert.Throws<DomainException>(() => context.MustNotCreateSingleton());
}
[Fact]
public void CanCreate_should_throw_exception_if_singleton()
public void Should_not_throw_exception_if_creating_singleton_content_with_schema_id()
{
var command = new CreateContent { Data = new ContentData() };
var context = CreateContext(CreateContent(Status.Draft, singletonSchema.Id), singletonSchema);
Assert.Throws<DomainException>(() => GuardContent.CanCreate(command, singleton));
context.MustNotCreateSingleton();
}
[Fact]
public void CanCreate_should_not_throw_exception_if_singleton_and_id_is_schema_id()
public void Should_not_throw_exception_if_creating_non_singleton_content()
{
var command = new CreateContent { Data = new ContentData(), ContentId = schema.Id };
var context = CreateContext(CreateContent(Status.Draft, singletonSchema.Id), normalSchema);
GuardContent.CanCreate(command, schema);
context.MustNotCreateSingleton();
}
[Fact]
public void CanCreate_should_not_throw_exception_if_data_is_not_null()
public void Should_throw_exception_if_changing_singleton_content()
{
var command = new CreateContent { Data = new ContentData() };
var context = CreateContext(CreateContent(Status.Draft), singletonSchema);
GuardContent.CanCreate(command, schema);
Assert.Throws<DomainException>(() => context.MustNotChangeSingleton(Status.Archived));
}
[Fact]
public async Task CanUpdate_should_throw_exception_if_data_is_null()
public void Should_not_throw_exception_if_changing_singleton_to_published()
{
SetupCanUpdate(true);
var context = CreateContext(CreateDraftContent(Status.Published, singletonSchema.Id), singletonSchema);
var content = CreateContent(Status.Draft);
var command = CreateCommand(new UpdateContent());
await ValidationAssert.ThrowsAsync(() => GuardContent.CanUpdate(command, content, workflow),
new ValidationError("Data is required.", "Data"));
context.MustNotChangeSingleton(Status.Published);
}
[Fact]
public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it()
public void Should_not_throw_exception_if_changing_non_singleton_content()
{
SetupCanUpdate(false);
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft, singletonSchema.Id), normalSchema);
var command = CreateCommand(new UpdateContent { Data = new ContentData() });
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanUpdate(command, content, workflow));
context.MustNotChangeSingleton(Status.Archived);
}
[Fact]
public async Task CanUpdate_should_throw_exception_if_workflow_blocks_it_but_check_is_disabled()
public void Should_throw_exception_if_deleting_singleton_content()
{
SetupCanUpdate(false);
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), singletonSchema);
var command = CreateCommand(new UpdateContent { Data = new ContentData(), DoNotValidateWorkflow = true });
await GuardContent.CanUpdate(command, content, workflow);
Assert.Throws<DomainException>(() => context.MustNotDeleteSingleton());
}
[Fact]
public async Task CanUpdate_should_not_throw_exception_if_data_is_not_null()
public void Should_not_throw_exception_if_deleting_non_singleton_content()
{
SetupCanUpdate(true);
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft, singletonSchema.Id), normalSchema);
var command = CreateCommand(new UpdateContent { Data = new ContentData() });
await GuardContent.CanUpdate(command, content, workflow);
context.MustNotDeleteSingleton();
}
[Fact]
public async Task CanChangeStatus_should_throw_exception_if_singleton()
public void Should_throw_exception_when_draft_already_created()
{
var content = CreateContent(Status.Published);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Draft });
var context = CreateContext(CreateDraftContent(Status.Draft), normalSchema);
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanChangeStatus(command, content, workflow, contentRepository, singleton));
Assert.Throws<DomainException>(() => context.MustCreateDraft());
}
[Fact]
public async Task CanChangeStatus_should_throw_exception_if_status_flow_not_valid()
public void Should_throw_exception_when_draft_cannot_be_created()
{
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Published });
A.CallTo(() => workflow.CanMoveToAsync(content, content.Status, command.Status, user))
.Returns(false);
await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(command, content, workflow, contentRepository, schema),
new ValidationError("Cannot change status from Draft to Published.", "Status"));
Assert.Throws<DomainException>(() => context.MustCreateDraft());
}
[Fact]
public async Task CanChangeStatus_should_throw_exception_if_status_valid()
public void Should_not_throw_exception_when_draft_can_be_created()
{
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Published), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = new Status("Invalid"), DoNotValidateWorkflow = true });
A.CallTo(() => workflow.GetInfoAsync(content, command.Status))
.Returns(Task.FromResult<StatusInfo?>(null));
await ValidationAssert.ThrowsAsync(() => GuardContent.CanChangeStatus(command, content, workflow, contentRepository, schema),
new ValidationError("Status is not defined in the workflow.", "Status"));
context.MustCreateDraft();
}
[Fact]
public async Task CanChangeStatus_should_throw_exception_if_referenced()
public void Should_throw_exception_when_draft_cannot_be_deleted()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Published), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Draft });
Assert.Throws<DomainException>(() => context.MustDeleteDraft());
}
A.CallTo(() => contentRepository.HasReferrersAsync(appId.Id, content.Id, SearchScope.Published))
.Returns(true);
[Fact]
public void Should_not_throw_exception_when_draft_can_be_deleted()
{
var context = CreateContext(CreateDraftContent(Status.Draft), normalSchema);
await Assert.ThrowsAsync<ValidationException>(() => GuardContent.CanChangeStatus(command, content, workflow, contentRepository, schema));
context.MustDeleteDraft();
}
[Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_not_valid_but_check_disabled()
public void Should_throw_exception_if_data_is_not_defined()
{
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Published, DoNotValidateWorkflow = true });
Assert.Throws<ValidationException>(() => context.MustHaveData(null));
}
A.CallTo(() => workflow.CanMoveToAsync(content, content.Status, command.Status, user))
.Returns(false);
[Fact]
public void Should_not_throw_exception_if_data_is_defined()
{
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
await GuardContent.CanChangeStatus(command, content, workflow, contentRepository, schema);
context.MustHaveData(new ContentData());
}
[Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_singleton_is_published()
public async Task Should_provide_initial_status()
{
var content = CreateDraftContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Published });
A.CallTo(() => contentWorkflow.GetInitialStatusAsync(context.Schema))
.Returns(Status.Archived);
await GuardContent.CanChangeStatus(command, content, workflow, contentRepository, singleton);
Assert.Equal(Status.Archived, await context.GetInitialStatusAsync());
}
[Fact]
public async Task CanChangeStatus_should_not_throw_exception_if_status_flow_valid()
public async Task Should_throw_exception_when_workflow_permits_update()
{
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new ChangeContentStatus { Status = Status.Published });
A.CallTo(() => workflow.CanMoveToAsync(content, content.Status, command.Status, user))
.Returns(true);
A.CallTo(() => contentWorkflow.CanUpdateAsync(context.Content, context.Content.EditingStatus(), context.User))
.Returns(false);
await GuardContent.CanChangeStatus(command, content, workflow, contentRepository, schema);
await Assert.ThrowsAsync<DomainException>(() => context.CheckUpdateAsync());
}
[Fact]
public void CreateDraft_should_throw_exception_if_not_published()
public async Task Should_not_throw_exception_when_workflow_allows_update()
{
var content = CreateContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new CreateContentDraft());
A.CallTo(() => contentWorkflow.CanUpdateAsync(context.Content, context.Content.EditingStatus(), context.User))
.Returns(true);
Assert.Throws<DomainException>(() => GuardContent.CanCreateDraft(command, content));
await context.CheckUpdateAsync();
}
[Fact]
public void CreateDraft_should_not_throw_exception()
public async Task Should_throw_exception_when_workflow_status_not_valid()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new CreateContentDraft());
A.CallTo(() => contentWorkflow.GetInfoAsync(((ContentEntity)context.Content), Status.Archived))
.Returns(Task.FromResult<StatusInfo?>(null));
GuardContent.CanCreateDraft(command, content);
await Assert.ThrowsAsync<ValidationException>(() => context.CheckStatusAsync(Status.Archived));
}
[Fact]
public void CanDeleteDraft_should_throw_exception_if_no_draft_found()
public async Task Should_not_throw_exception_when_workflow_status_is_valid()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new DeleteContentDraft());
A.CallTo(() => contentWorkflow.GetInfoAsync(((ContentEntity)context.Content), Status.Archived))
.Returns(new StatusInfo(Status.Archived, StatusColors.Archived));
Assert.Throws<DomainException>(() => GuardContent.CanDeleteDraft(command, content));
await context.CheckStatusAsync(Status.Archived);
}
[Fact]
public void CanDeleteDraft_should_not_throw_exception()
public async Task Should_not_throw_exception_when_workflow_status_is_checked_for_singleton()
{
var content = CreateDraftContent(Status.Draft);
var context = CreateContext(CreateContent(Status.Draft), singletonSchema);
var command = CreateCommand(new DeleteContentDraft());
await context.CheckStatusAsync(Status.Archived);
GuardContent.CanDeleteDraft(command, content);
A.CallTo(() => contentWorkflow.GetInfoAsync(((ContentEntity)context.Content), Status.Archived))
.MustNotHaveHappened();
}
[Fact]
public async Task CanDelete_should_throw_exception_if_singleton()
public async Task Should_throw_exception_when_workflow_transition_not_valid()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new DeleteContent());
A.CallTo(() => contentWorkflow.CanMoveToAsync(((ContentEntity)context.Content), Status.Draft, Status.Archived, context.User))
.Returns(false);
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanDelete(command, content, contentRepository, singleton));
await Assert.ThrowsAsync<ValidationException>(() => context.CheckTransitionAsync(Status.Archived));
}
[Fact]
public async Task CanDelete_should_throw_exception_if_referenced()
public async Task Should_not_throw_exception_when_workflow_transition_is_valid()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var command = CreateCommand(new DeleteContent { CheckReferrers = true });
A.CallTo(() => contentRepository.HasReferrersAsync(appId.Id, content.Id, SearchScope.All))
A.CallTo(() => contentWorkflow.CanMoveToAsync(((ContentEntity)context.Content), Status.Draft, Status.Archived, context.User))
.Returns(true);
await Assert.ThrowsAsync<DomainException>(() => GuardContent.CanDelete(command, content, contentRepository, schema));
await context.CheckTransitionAsync(Status.Archived);
}
[Fact]
public async Task CanDelete_should_not_throw_exception()
public async Task Should_not_throw_exception_when_workflow_transition_is_checked_for_singleton()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), singletonSchema);
var command = CreateCommand(new DeleteContent());
await context.CheckTransitionAsync(Status.Archived);
await GuardContent.CanDelete(command, content, contentRepository, schema);
A.CallTo(() => contentWorkflow.CanMoveToAsync(((ContentEntity)context.Content), A<Status>._, A<Status>._, A<ClaimsPrincipal>._))
.MustNotHaveHappened();
}
[Fact]
public void CheckPermission_should_not_throw_exception_if_content_is_from_current_user()
public void Should_not_throw_exception_if_content_is_from_another_user_but_user_has_permission()
{
var content = CreateContent(status: Status.Published);
var userPermission = Permissions.ForApp(Permissions.AppContentsDelete, appId.Name, schemaId.Name).Id;
var userObject = Mocks.FrontendUser(permission: userPermission);
var context = CreateContext(CreateContent(Status.Draft), normalSchema, userObject);
var command = CreateCommand(new DeleteContent());
((ContentEntity)context.Content).CreatedBy = RefToken.User("456");
GuardContent.CheckPermission(content, command, Permissions.AppContentsDelete);
context.MustHavePermission(Permissions.AppContentsDelete);
}
[Fact]
public void CheckPermission_should_not_throw_exception_if_user_is_null()
public void Should_not_throw_exception_if_content_is_from_current_user()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var commandActor = RefToken.User("456");
var command = CreateCommand(new DeleteContent { Actor = commandActor });
((ContentEntity)context.Content).CreatedBy = actor;
command.User = null;
GuardContent.CheckPermission(content, command, Permissions.AppContentsDelete);
context.MustHavePermission(Permissions.AppContentsDelete);
}
[Fact]
public void CheckPermission_should_not_throw_exception_if_content_is_from_another_user_but_user_has_permission()
public void Should_not_throw_exception_if_user_is_null()
{
var content = CreateContent(Status.Published);
var permission = Permissions.ForApp(Permissions.AppContentsDelete, appId.Name, schemaId.Name).Id;
var context = CreateContext(CreateContent(Status.Draft), normalSchema, null);
var commandUser = Mocks.FrontendUser(permission: permission);
var commandActor = RefToken.User("456");
var command = CreateCommand(new DeleteContent { Actor = commandActor, User = commandUser });
((ContentEntity)context.Content).CreatedBy = RefToken.User("456");
GuardContent.CheckPermission(content, command, Permissions.AppContentsDelete);
context.MustHavePermission(Permissions.AppContentsDelete);
}
[Fact]
public void CheckPermission_should_exception_if_content_is_from_another_user_and_user_has_no_permission()
public void Should_throw_exception_if_content_is_from_another_user_and_user_has_no_permission()
{
var content = CreateContent(Status.Published);
var context = CreateContext(CreateContent(Status.Draft), normalSchema);
var commandActor = RefToken.User("456");
var command = CreateCommand(new DeleteContent { Actor = commandActor });
((ContentEntity)((ContentEntity)context.Content)).CreatedBy = RefToken.User("456");
Assert.Throws<DomainForbiddenException>(() => GuardContent.CheckPermission(content, command, Permissions.AppContentsDelete));
Assert.Throws<DomainForbiddenException>(() => context.MustHavePermission(Permissions.AppContentsDelete));
}
private void SetupCanUpdate(bool canUpdate)
private OperationContext CreateContext(ContentEntity content, ISchemaEntity contextSchema)
{
A.CallTo(() => workflow.CanUpdateAsync(A<IContentEntity>._, A<Status>._, user))
.Returns(canUpdate);
return CreateContext(content, contextSchema, user);
}
private T CreateCommand<T>(T command) where T : ContentCommand
private OperationContext CreateContext(ContentEntity content, ISchemaEntity contextSchema, ClaimsPrincipal? currentUser)
{
if (command.Actor == null)
{
command.Actor = actor;
}
var serviceProvider =
new ServiceCollection()
.AddSingleton(contentRepository)
.AddSingleton(contentWorkflow)
.BuildServiceProvider();
if (command.User == null)
return new OperationContext(serviceProvider)
{
command.User = user;
}
return command;
Actor = actor,
App = Mocks.App(appId),
ContentProvider = () => content,
ContentId = content.Id,
Schema = contextSchema,
User = currentUser
};
}
private IContentEntity CreateDraftContent(Status status)
private ContentEntity CreateDraftContent(Status status, DomainId? id = null)
{
return CreateContentCore(new ContentEntity { NewStatus = status });
return CreateContentCore(new ContentEntity { NewStatus = status }, id);
}
private IContentEntity CreateContent(Status status)
private ContentEntity CreateContent(Status status, DomainId? id = null)
{
return CreateContentCore(new ContentEntity { Status = status });
return CreateContentCore(new ContentEntity { Status = status }, id);
}
private IContentEntity CreateContentCore(ContentEntity content)
private ContentEntity CreateContentCore(ContentEntity content, DomainId? id = null)
{
content.Id = DomainId.NewGuid();
content.Id = id ?? DomainId.NewGuid();
content.AppId = appId;
content.Created = default;
content.CreatedBy = actor;

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs

@ -123,30 +123,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards
await GuardRule.CanUpdate(command, Rule(), appProvider);
}
[Fact]
public void CanEnable_should_not_throw_exception()
{
var command = new EnableRule();
GuardRule.CanEnable(command);
}
[Fact]
public void CanDisable_should_not_throw_exception()
{
var command = new DisableRule();
GuardRule.CanDisable(command);
}
[Fact]
public void CanDelete_should_not_throw_exception()
{
var command = new DeleteRule();
GuardRule.CanDelete(command);
}
private CreateRule CreateCommand(CreateRule command)
{
command.AppId = appId;

32
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/DomainObject/Guards/GuardSchemaTests.cs

@ -602,22 +602,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
GuardSchema.CanConfigureFieldRules(command);
}
[Fact]
public void CanPublish_should_not_throw_exception()
{
var command = new PublishSchema();
GuardSchema.CanPublish(command);
}
[Fact]
public void CanUnpublish_should_not_throw_exception()
{
var command = new UnpublishSchema();
GuardSchema.CanUnpublish(command);
}
[Fact]
public void CanReorder_should_throw_exception_if_field_ids_contains_invalid_id()
{
@ -678,22 +662,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject.Guards
GuardSchema.CanConfigurePreviewUrls(command);
}
[Fact]
public void CanChangeCategory_should_not_throw_exception()
{
var command = new ChangeCategory();
GuardSchema.CanChangeCategory(command);
}
[Fact]
public void CanDelete_should_not_throw_exception()
{
var command = new DeleteSchema();
GuardSchema.CanDelete(command);
}
private CreateSchema CreateCommand(CreateSchema command)
{
command.AppId = appId;

20
backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs

@ -280,26 +280,6 @@ namespace TestSuite.ApiTests
Assert.Single(assets_1.Items, x => x.Id == asset_1.Id);
}
[Fact]
public async Task Should_move_asset_to_folder_by_path()
{
// STEP 1: Create asset
var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png");
// STEP 2: Move dynamically
var asset_2 = await _.Assets.PutAssetParentAsync(_.AppName, asset_1.Id, new MoveAssetDto
{
ParentPath = "path/to/folder"
});
// STEP 3: Get folder
var folder_1 = await _.Assets.GetAssetFoldersAsync(_.AppName, asset_2.ParentId);
Assert.Equal("path/to/folder", string.Join("/", folder_1.Path.Select(x => x.FolderName)));
}
[Fact, Trait("Category", "NotAutomated")]
public async Task Should_delete_recursively()
{

8
frontend/app/features/assets/pages/asset-tags.component.ts

@ -6,7 +6,7 @@
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Tag, TagsSelected } from '@app/shared';
import { TagItem, TagsSelected } from '@app/shared';
@Component({
selector: 'sqx-asset-tags',
@ -22,7 +22,7 @@ export class AssetTagsComponent {
public toggle = new EventEmitter<string>();
@Input()
public tags: ReadonlyArray<Tag>;
public tags: ReadonlyArray<TagItem>;
@Input()
public tagsSelected: TagsSelected;
@ -31,11 +31,11 @@ export class AssetTagsComponent {
return Object.keys(this.tagsSelected).length === 0;
}
public isSelected(tag: Tag) {
public isSelected(tag: TagItem) {
return this.tagsSelected[tag.name] === true;
}
public trackByTag(_index: number, tag: Tag) {
public trackByTag(_index: number, tag: TagItem) {
return tag.name;
}
}

2
frontend/app/features/content/shared/forms/assets-editor.component.html

@ -26,6 +26,7 @@
[assetFile]="file"
[isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact"
[folderId]="folderId"
(loadError)="removeLoadingAsset(file)"
(load)="addAsset(file, $event)">
</sqx-asset>
@ -47,6 +48,7 @@
(load)="addAsset(file, $event)"
(loadError)="removeLoadingAsset(file)"
[assetFile]="file"
[folderId]="folderId"
[isCompact]="snapshot.isCompact"
[isDisabled]="snapshot.isDisabled"
[isListView]="true">

5
frontend/app/features/content/shared/forms/assets-editor.component.ts

@ -6,7 +6,7 @@
*/
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { AppsState, AssetDto, AssetsService, DialogModel, LocalStoreService, MessageBus, Settings, sorted, StatefulControlComponent, Types } from '@app/shared';
@ -46,6 +46,9 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AssetsEditorComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnInit {
@Input()
public folderId?: string;
public assetsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef,

14
frontend/app/features/content/shared/forms/field-editor.component.html

@ -30,7 +30,7 @@
</sqx-array-editor>
</ng-container>
<ng-container *ngSwitchCase="'Assets'">
<sqx-assets-editor [formControl]="editorControl"></sqx-assets-editor>
<sqx-assets-editor [formControl]="editorControl" [folderId]="field.rawProperties.folderId"></sqx-assets-editor>
</ng-container>
<ng-container *ngSwitchCase="'Boolean'">
<ng-container [ngSwitch]="field.rawProperties.editor">
@ -46,13 +46,13 @@
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="'DateTime'">
<sqx-date-time-editor enforceTime="true" [mode]="field.rawProperties.editor" [formControl]="editorControl"></sqx-date-time-editor>
<sqx-date-time-editor [formControl]="editorControl" [mode]="field.rawProperties.editor" enforceTime="true"></sqx-date-time-editor>
</ng-container>
<ng-container *ngSwitchCase="'Geolocation'">
<sqx-geolocation-editor [formControl]="editorControl"></sqx-geolocation-editor>
</ng-container>
<ng-container *ngSwitchCase="'Json'">
<sqx-code-editor height="350" [formControl]="editorControl" valueMode="Json"></sqx-code-editor>
<sqx-code-editor [formControl]="editorControl" valueMode="Json" height="350"></sqx-code-editor>
</ng-container>
<ng-container *ngSwitchCase="'Number'">
<ng-container [ngSwitch]="field.rawProperties.editor">
@ -122,16 +122,16 @@
<input class="form-control" type="text" [formControl]="editorControl" [placeholder]="field.displayPlaceholder" sqxTransformInput="Slugify">
</ng-container>
<ng-container *ngSwitchCase="'TextArea'">
<textarea class="form-control" [formControl]="editorControl" rows="5" [placeholder]="field.displayPlaceholder"></textarea>
<textarea class="form-control" [formControl]="editorControl" [placeholder]="field.displayPlaceholder" rows="5"></textarea>
</ng-container>
<ng-container *ngSwitchCase="'RichText'">
<sqx-rich-editor [formControl]="editorControl" #editor></sqx-rich-editor>
<sqx-rich-editor [formControl]="editorControl" #editor [folderId]="field.rawProperties.folderId"></sqx-rich-editor>
</ng-container>
<ng-container *ngSwitchCase="'Html'">
<sqx-code-editor height="350" [formControl]="editorControl" mode="ace/mode/html"></sqx-code-editor>
<sqx-code-editor [formControl]="editorControl" #editor mode="ace/mode/html" height="350" ></sqx-code-editor>
</ng-container>
<ng-container *ngSwitchCase="'Markdown'">
<sqx-markdown-editor [formControl]="editorControl"></sqx-markdown-editor>
<sqx-markdown-editor [formControl]="editorControl" #editor [folderId]="field.rawProperties.folderId"></sqx-markdown-editor>
</ng-container>
<ng-container *ngSwitchCase="'StockPhoto'">
<sqx-stock-photo-editor [formControl]="editorControl"></sqx-stock-photo-editor>

6
frontend/app/features/content/shared/forms/stock-photo-editor.component.ts

@ -15,8 +15,6 @@ export const SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StockPhotoEditorComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
interface State {
// True when loading assets.
isLoading?: boolean;
@ -86,9 +84,9 @@ export class StockPhotoEditorComponent extends StatefulControlComponent<State, s
super.setDisabledState(isDisabled);
if (isDisabled) {
this.stockPhotoSearch.disable(NO_EMIT);
this.stockPhotoSearch.disable({ emitEvent: false });
} else {
this.stockPhotoSearch.enable(NO_EMIT);
this.stockPhotoSearch.enable({ emitEvent: false });
}
}

12
frontend/app/features/schemas/pages/schema/fields/types/assets-ui.component.html

@ -30,4 +30,16 @@
</sqx-form-hint>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_folderId">{{ 'schemas.fieldTypes.assets.folderId' | sqxTranslate }}</label>
<div class="col-9">
<sqx-asset-folder-dropdown formControlName="folderId"></sqx-asset-folder-dropdown>
<sqx-form-hint>
{{ 'schemas.fieldTypes.assets.folderIdHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>
</div>

3
frontend/app/features/schemas/pages/schema/fields/types/assets-ui.component.ts

@ -30,5 +30,8 @@ export class AssetsUIComponent implements OnInit {
this.fieldForm.setControl('resolveFirst',
new FormControl(this.properties.resolveFirst));
this.fieldForm.setControl('folderId',
new FormControl(this.properties.folderId));
}
}

15
frontend/app/features/schemas/pages/schema/fields/types/string-ui.component.html

@ -10,6 +10,7 @@
</sqx-form-hint>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.field.editor' | sqxTranslate }}</label>
@ -86,6 +87,7 @@
</label>
</div>
</div>
<div class="form-group row" [class.hidden]="hideAllowedValues | async">
<label class="col-3 col-form-label">{{ 'schemas.field.allowedValues' | sqxTranslate }}</label>
@ -93,6 +95,7 @@
<sqx-tag-editor formControlName="allowedValues"></sqx-tag-editor>
</div>
</div>
<div class="form-group row" [class.hidden]="hideInlineEditable | async">
<div class="col-9 offset-3">
<div class="custom-control custom-checkbox">
@ -103,4 +106,16 @@
</div>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label" for="{{field.fieldId}}_folderId">{{ 'schemas.fieldTypes.string.folderId' | sqxTranslate }}</label>
<div class="col-9">
<sqx-asset-folder-dropdown formControlName="folderId"></sqx-asset-folder-dropdown>
<sqx-form-hint>
{{ 'schemas.fieldTypes.string.folderIdHint' | sqxTranslate }}
</sqx-form-hint>
</div>
</div>
</div>

3
frontend/app/features/schemas/pages/schema/fields/types/string-ui.component.ts

@ -43,6 +43,9 @@ export class StringUIComponent extends ResourceOwner implements OnInit {
this.fieldForm.setControl('inlineEditable',
new FormControl(this.properties.inlineEditable));
this.fieldForm.setControl('folderId',
new FormControl(this.properties.folderId));
this.hideAllowedValues =
value$<string>(this.fieldForm.controls['editor']).pipe(map(x => !(x && (x === 'Radio' || x === 'Dropdown'))));

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

@ -51,6 +51,7 @@
{{patternName}}
</small>
</div>
<div class="form-group row" *ngIf="showPatternMessage | async">
<label class="col-3 col-form-label" for="{{field.fieldId}}_fieldPatternMessage">{{ 'schemas.fieldTypes.string.patternMessage' | sqxTranslate }}</label>
@ -66,6 +67,7 @@
</sqx-form-hint>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.fieldTypes.string.contentType' | sqxTranslate }}</label>
@ -75,6 +77,7 @@
</select>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.fieldTypes.string.characters' | sqxTranslate }}</label>
@ -92,6 +95,7 @@
</div>
</div>
</div>
<div class="form-group row">
<label class="col-3 col-form-label">{{ 'schemas.fieldTypes.string.words' | sqxTranslate }}</label>

1
frontend/app/framework/angular/forms/editors/autocomplete.component.ts

@ -168,6 +168,7 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
public setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.resetState();
this.queryInput.disable(NO_EMIT);
} else {
this.queryInput.enable(NO_EMIT);

12
frontend/app/framework/angular/forms/editors/code-editor.component.ts

@ -67,6 +67,12 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
super(changeDetector, {});
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['filePath'] || changes['mode']) {
this.setMode();
}
}
public writeValue(obj: string) {
if (this.valueMode === 'Json') {
if (obj === null) {
@ -103,12 +109,6 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
}
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['filePath'] || changes['mode']) {
this.setMode();
}
}
public ngAfterViewInit() {
this.valueChanged.pipe(debounceTime(500))
.subscribe(() => {

22
frontend/app/framework/angular/forms/editors/dropdown.component.ts

@ -16,8 +16,6 @@ export const SQX_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => DropdownComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
interface State {
// The suggested item.
suggestedItems: ReadonlyArray<any>;
@ -26,7 +24,7 @@ interface State {
suggestedIndex: number;
// The selected item.
selectedItem?: number;
selectedItem?: any;
// The current search query.
query?: RegExp;
@ -109,12 +107,10 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
if (changes['items']) {
this.items = this.items || [];
this.resetSearch();
this.next({ suggestedItems: this.items });
this.next({
suggestedIndex: this.getSelectedIndex(this.value),
suggestedItems: this.items || []
});
this.selectSearch('');
this.selectIndex(this.getSelectedIndex(this.value), false);
}
}
@ -142,9 +138,9 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
super.setDisabledState(isDisabled);
if (isDisabled) {
this.queryInput.disable(NO_EMIT);
this.queryInput.disable({ emitEvent: false });
} else {
this.queryInput.enable(NO_EMIT);
this.queryInput.enable({ emitEvent: false });
}
}
@ -168,7 +164,7 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
public open() {
if (!this.dropdown.isOpen) {
this.resetSearch();
this.selectSearch('');
}
this.dropdown.show();
@ -186,8 +182,8 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
this.dropdown.hide();
}
private resetSearch() {
this.queryInput.setValue('');
private selectSearch(value: string) {
this.queryInput.setValue(value);
}
public selectPrevIndex() {

6
frontend/app/framework/angular/forms/editors/tag-editor.component.ts

@ -20,8 +20,6 @@ export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
let CACHED_FONT: string;
const NO_EMIT = { emitEvent: false };
interface State {
// True, when the item has the focus.
hasFocus: boolean;
@ -192,9 +190,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
super.setDisabledState(isDisabled);
if (isDisabled) {
this.addInput.disable(NO_EMIT);
this.addInput.disable({ emitEvent: false });
} else {
this.addInput.enable(NO_EMIT);
this.addInput.enable({ emitEvent: false });
}
}

0
frontend/app/framework/angular/highlight.pipe.ts → frontend/app/framework/angular/pipes/highlight.pipe.ts

2
frontend/app/framework/declarations.ts

@ -35,7 +35,6 @@ export * from './angular/forms/progress-bar.component';
export * from './angular/forms/transform-input.directive';
export * from './angular/forms/undefinable-form-array';
export * from './angular/forms/validators';
export * from './angular/highlight.pipe';
export * from './angular/hover-background.directive';
export * from './angular/http/caching.interceptor';
export * from './angular/http/http-extensions';
@ -55,6 +54,7 @@ export * from './angular/panel-container.directive';
export * from './angular/panel.component';
export * from './angular/pipes/colors.pipes';
export * from './angular/pipes/date-time.pipes';
export * from './angular/pipes/highlight.pipe';
export * from './angular/pipes/keys.pipe';
export * from './angular/pipes/markdown.pipe';
export * from './angular/pipes/money.pipe';

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

@ -0,0 +1,5 @@
<sqx-dropdown [formControl]="control" [items]="snapshot.assetFolders" valueProperty="id" searchProperty="folderName">
<ng-template let-assetFolder="$implicit" let-context="context">
<span class="truncate">{{assetFolder.folderName | sqxTranslate | sqxHighlight:context}}</span>
</ng-template>
</sqx-dropdown>

0
frontend/app/shared/components/assets/asset-folder-dropdown.component.scss

76
frontend/app/shared/components/assets/asset-folder-dropdown.component.ts

@ -0,0 +1,76 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnInit } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MathHelper, StatefulControlComponent, value$ } from '@app/framework';
import { AssetPathItem, AssetsService } from '@app/shared/internal';
import { AppsState } from '@app/shared/state/apps.state';
import { ROOT_ITEM } from '@app/shared/state/assets.state';
export const SQX_ASSETS_FOLDER_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetFolderDropdownComponent), multi: true
};
interface State {
// The asset folders.
assetFolders: ReadonlyArray<AssetPathItem>;
}
@Component({
selector: 'sqx-asset-folder-dropdown',
styleUrls: ['./asset-folder-dropdown.component.scss'],
templateUrl: './asset-folder-dropdown.component.html',
providers: [
SQX_ASSETS_FOLDER_DROPDOWN_CONTROL_VALUE_ACCESSOR
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AssetFolderDropdownComponent extends StatefulControlComponent<State, any> implements OnInit, ControlValueAccessor {
public control = new FormControl();
constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState,
private readonly assetsService: AssetsService
) {
super(changeDetector, {
assetFolders: []
});
this.own(
value$(this.control)
.subscribe((value: any) => {
if (this.control.enabled) {
this.callChange(value);
this.callTouched();
}
}));
}
public ngOnInit() {
this.assetsService.getAssetFolders(this.appsState.appName, MathHelper.EMPTY_GUID)
.subscribe(dto => {
const assetFolders = [ROOT_ITEM, ...dto.items];
this.next({ assetFolders });
});
}
public setDisabledState(isDisabled: boolean) {
super.setDisabledState(isDisabled);
if (isDisabled) {
this.control.disable({ emitEvent: false });
} else {
this.control.enable({ emitEvent: false });
}
}
public writeValue(obj: any): void {
this.control.setValue(obj || ROOT_ITEM.id);
}
}

5
frontend/app/shared/components/assets/asset.component.ts

@ -47,6 +47,9 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
@Input()
public assetsState: AssetsState;
@Input()
public folderId?: string;
@Input()
public removeMode = false;
@ -85,7 +88,7 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
if (assetFile) {
this.setProgress(1);
this.assetUploader.uploadFile(assetFile, this.assetsState)
this.assetUploader.uploadFile(assetFile, this.assetsState, this.folderId)
.subscribe(dto => {
if (Types.isNumber(dto)) {
this.setProgress(dto);

7
frontend/app/shared/components/forms/markdown-editor.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Renderer2, ViewChild } from '@angular/core';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, Renderer2, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal';
import marked from 'marked';
@ -35,6 +35,9 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
private value: string;
private isDisabled = false;
@Input()
public folderId?: string;
@ViewChild('editor', { static: false })
public editor: ElementRef;
@ -240,7 +243,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
}
};
this.assetUploader.uploadFile(file)
this.assetUploader.uploadFile(file, undefined, this.folderId)
.subscribe(asset => {
if (Types.is(asset, AssetDto)) {
replaceText(this.buildMarkup(asset));

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

@ -1,3 +1,3 @@
<sqx-checkbox-group [formControl]="selectionControl"
<sqx-checkbox-group [formControl]="control"
[values]="snapshot.converter.suggestions">
</sqx-checkbox-group>

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

@ -44,7 +44,7 @@ export class ReferencesCheckboxesComponent extends StatefulControlComponent<Stat
return !!this.schemaId && !!this.language;
}
public selectionControl = new FormControl([]);
public control = new FormControl([]);
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions,
private readonly appsState: AppsState,
@ -58,7 +58,7 @@ export class ReferencesCheckboxesComponent extends StatefulControlComponent<Stat
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own(
this.selectionControl.valueChanges
this.control.valueChanges
.subscribe((value: string[]) => {
if (value && value.length > 0) {
this.callTouched();
@ -96,17 +96,17 @@ export class ReferencesCheckboxesComponent extends StatefulControlComponent<Stat
}
public setDisabledState(isDisabled: boolean) {
super.setDisabledState(isDisabled);
if (isDisabled) {
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
} else if (this.isValid) {
this.selectionControl.enable(NO_EMIT);
this.control.enable(NO_EMIT);
}
super.setDisabledState(isDisabled);
}
public writeValue(obj: ReadonlyArray<string>) {
this.selectionControl.setValue(obj, NO_EMIT);
this.control.setValue(obj, NO_EMIT);
}
private resetConverterState() {
@ -115,11 +115,11 @@ export class ReferencesCheckboxesComponent extends StatefulControlComponent<Stat
if (this.isValid && this.contentItems && this.contentItems.length > 0) {
converter = new ReferencesTagsConverter(this.language, this.contentItems, this.localizer);
this.selectionControl.enable(NO_EMIT);
this.control.enable(NO_EMIT);
} else {
converter = new ReferencesTagsConverter(null!, [], this.localizer);
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
}
this.next({ converter });

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

@ -1,4 +1,4 @@
<sqx-dropdown [formControl]="selectionControl" [items]="snapshot.contentNames">
<sqx-dropdown [formControl]="control" [items]="snapshot.contentNames">
<ng-template let-content="$implicit" let-context="context">
<span class="truncate" [innerHTML]="content.name | sqxHighlight:context"></span>
</ng-template>

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

@ -62,7 +62,7 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
return !!this.schemaId && !!this.languageField;
}
public selectionControl = new FormControl('');
public control = new FormControl('');
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions,
private readonly appsState: AppsState,
@ -77,9 +77,9 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own(
value$(this.selectionControl)
value$(this.control)
.subscribe((value: ContentName) => {
if (this.selectionControl.enabled) {
if (this.control.enabled) {
if (value && value.id) {
if (this.mode === 'Single') {
this.callChange(value.id);
@ -112,22 +112,22 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
this.selectContent();
}, () => {
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
});
} else {
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
}
}
}
public setDisabledState(isDisabled: boolean) {
super.setDisabledState(isDisabled);
if (isDisabled) {
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
} else if (this.isValid) {
this.selectionControl.enable(NO_EMIT);
this.control.enable(NO_EMIT);
}
super.setDisabledState(isDisabled);
}
public writeValue(obj: any) {
@ -147,11 +147,11 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
}
private selectContent() {
this.selectionControl.setValue(this.snapshot.contentNames.find(x => x.id === this.selectedId), NO_EMIT);
this.control.setValue(this.snapshot.contentNames.find(x => x.id === this.selectedId), NO_EMIT);
}
private unselectContent() {
this.selectionControl.setValue(undefined, NO_EMIT);
this.control.setValue(undefined, NO_EMIT);
}
private createContentNames(contents: ReadonlyArray<ContentDto>): ReadonlyArray<ContentName> {

3
frontend/app/shared/components/forms/references-tags.component.html

@ -1,2 +1,3 @@
<sqx-tag-editor placeholder="{{ 'common.tagAddReference' | sqxTranslate }}" [converter]="snapshot.converter" [formControl]="selectionControl" [suggestions]="snapshot.converter.suggestions">
<sqx-tag-editor placeholder="{{ 'common.tagAddReference' | sqxTranslate }}"
[converter]="snapshot.converter" [formControl]="control" [suggestions]="snapshot.converter.suggestions">
</sqx-tag-editor>

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

@ -44,7 +44,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
return !!this.schemaId && !!this.language;
}
public selectionControl = new FormControl([]);
public control = new FormControl([]);
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions,
private readonly appsState: AppsState,
@ -58,7 +58,7 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own(
this.selectionControl.valueChanges
this.control.valueChanges
.subscribe((value: string[]) => {
if (value && value.length > 0) {
this.callTouched();
@ -97,16 +97,16 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
public setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
} else if (this.isValid) {
this.selectionControl.enable(NO_EMIT);
this.control.enable(NO_EMIT);
}
super.setDisabledState(isDisabled);
}
public writeValue(obj: ReadonlyArray<string>) {
this.selectionControl.setValue(obj, NO_EMIT);
this.control.setValue(obj, NO_EMIT);
}
private resetConverterState() {
@ -115,11 +115,11 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
if (this.isValid && this.contentItems && this.contentItems.length > 0) {
converter = new ReferencesTagsConverter(this.language, this.contentItems, this.localizer);
this.selectionControl.enable(NO_EMIT);
this.control.enable(NO_EMIT);
} else {
converter = new ReferencesTagsConverter(null!, [], this.localizer);
this.selectionControl.disable(NO_EMIT);
this.control.disable(NO_EMIT);
}
this.next({ converter });

7
frontend/app/shared/components/forms/rich-editor.component.ts

@ -7,7 +7,7 @@
// tslint:disable: prefer-for-of
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, OnDestroy, Output, ViewChild } from '@angular/core';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ApiUrlConfig, AssetDto, AssetUploaderState, DialogModel, LocalizerService, ResourceLoaderService, StatefulControlComponent, Types, UploadCanceled } from '@app/shared/internal';
@ -33,6 +33,9 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
@Output()
public assetPluginClick = new EventEmitter<any>();
@Input()
public folderId: string;
@ViewChild('editor', { static: false })
public editor: ElementRef;
@ -238,7 +241,7 @@ export class RichEditorComponent extends StatefulControlComponent<{}, string> im
this.tinyEditor.setContent(content);
};
this.assetUploader.uploadFile(file)
this.assetUploader.uploadFile(file, undefined, this.folderId)
.subscribe(asset => {
if (Types.is(asset, AssetDto)) {
replaceText(this.buildMarkup(asset));

1
frontend/app/shared/declarations.ts

@ -9,6 +9,7 @@ export * from './components/app-form.component';
export * from './components/assets/asset-dialog.component';
export * from './components/assets/asset-folder-dialog.component';
export * from './components/assets/asset-folder.component';
export * from './components/assets/asset-folder-dropdown.component';
export * from './components/assets/asset-history.component';
export * from './components/assets/asset-path.component';
export * from './components/assets/asset-text-editor.component';

3
frontend/app/shared/module.ts

@ -14,6 +14,7 @@ import { RouterModule } from '@angular/router';
import { SqxFrameworkModule } from '@app/framework';
import { MentionModule } from 'angular-mentions';
import { NgxDocViewerModule } from 'ngx-doc-viewer';
import { AssetFolderDropdownComponent } from './components/assets/asset-folder-dropdown.component';
import { PreviewableType } from './components/assets/pipes';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PatternsService, PatternsState, PlansService, PlansState, QueryComponent, QueryListComponent, QueryPathComponent, ReferencesCheckboxesComponent, ReferencesDropdownComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
import { SearchService } from './services/search.service';
@ -32,6 +33,7 @@ import { SearchService } from './services/search.service';
AssetDialogComponent,
AssetFolderComponent,
AssetFolderDialogComponent,
AssetFolderDropdownComponent,
AssetHistoryComponent,
AssetPathComponent,
AssetPreviewUrlPipe,
@ -82,6 +84,7 @@ import { SearchService } from './services/search.service';
AssetDialogComponent,
AssetFolderComponent,
AssetFolderDialogComponent,
AssetFolderDropdownComponent,
AssetPathComponent,
AssetPreviewUrlPipe,
AssetsListComponent,

14
frontend/app/shared/services/assets.service.spec.ts

@ -228,11 +228,11 @@ describe('AssetsService', () => {
let asset: AssetDto;
assetsService.postAssetFile('my-app', null!, 'parent1').subscribe(result => {
assetsService.postAssetFile('my-app', null!).subscribe(result => {
asset = <AssetDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?parentId=parent1');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -242,16 +242,16 @@ describe('AssetsService', () => {
expect(asset!).toEqual(createAsset(12));
}));
it('should make post without parent id to create asset',
it('should make post with parent id to create asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: AssetDto;
assetsService.postAssetFile('my-app', null!).subscribe(result => {
assetsService.postAssetFile('my-app', null!, 'parent1').subscribe(result => {
asset = <AssetDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?parentId=parent1');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -267,13 +267,13 @@ describe('AssetsService', () => {
let asset: AssetDto;
let error: ErrorDto;
assetsService.postAssetFile('my-app', null!, 'parent1').subscribe(result => {
assetsService.postAssetFile('my-app', null!).subscribe(result => {
asset = <AssetDto>result;
}, e => {
error = e;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets?parentId=parent1');
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();

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

@ -138,8 +138,7 @@ export interface AnnotateAssetDto {
readonly metadata?: { [key: string]: any };
}
export interface CreateAssetFolderDto {
readonly parentId?: string;
export interface CreateAssetFolderDto extends MoveAssetItemDto {
readonly folderName: string;
}
@ -256,7 +255,7 @@ export class AssetsService {
}
}
public getAssetFolders(appName: string, parentId?: string): Observable<AssetFoldersDto> {
public getAssetFolders(appName: string, parentId: string): Observable<AssetFoldersDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/folders?parentId=${parentId}`);
return this.http.get<{ total: number, items: any[], folders: any[], path: any[] } & Resource>(url).pipe(

2
frontend/app/shared/services/schemas.types.ts

@ -188,6 +188,7 @@ export class AssetsFieldPropertiesDto extends FieldPropertiesDto {
public readonly resolveFirst: boolean;
public readonly aspectHeight?: number;
public readonly aspectWidth?: number;
public readonly folderId?: string;
public readonly maxHeight?: number;
public readonly maxItems?: number;
public readonly maxSize?: number;
@ -385,6 +386,7 @@ export class StringFieldPropertiesDto extends FieldPropertiesDto {
public readonly editor: StringFieldEditor = 'Input';
public readonly inlineEditable: boolean = false;
public readonly isUnique: boolean = false;
public readonly folderId?: string;
public readonly maxLength?: number;
public readonly minLength?: number;
public readonly maxWords?: number;

6
frontend/app/shared/state/asset-uploader.state.ts

@ -63,10 +63,8 @@ export class AssetUploaderState extends State<Snapshot> {
}, 'Stopped');
}
public uploadFile(file: File, target?: AssetsState): Observable<UploadResult> {
const parentId = target?.parentId;
const stream = this.assetsService.postAssetFile(this.appName, file, parentId);
public uploadFile(file: File, target?: AssetsState, parentId?: string): Observable<UploadResult> {
const stream = this.assetsService.postAssetFile(this.appName, file, parentId ?? target?.parentId);
return this.upload(stream, MathHelper.guid(), file.name, asset => {
if (asset.isDuplicate) {

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

@ -17,7 +17,7 @@ export type AssetPathItem = { id: string, folderName: string };
export type TagsAvailable = { [name: string]: number };
export type TagsSelected = { [name: string]: boolean };
export type Tag = { name: string, count: number; };
export type TagItem = { name: string, count: number; };
export const ROOT_ITEM: AssetPathItem = { id: MathHelper.EMPTY_GUID, folderName: 'i18n:assets.specialFolder.root' };

Loading…
Cancel
Save