Browse Source

Continued with draft.

pull/285/head
Sebastian 9 years ago
parent
commit
81eaff9f71
  1. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  2. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  3. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs
  4. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs
  5. 131
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  6. 116
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs
  7. 54
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  8. 46
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs
  9. 140
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  10. 16
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs
  11. 70
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  12. 33
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs
  13. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  14. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs
  15. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs
  16. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  17. 76
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  18. 7
      src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  19. 19
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  20. 9
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  21. 4
      src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs
  22. 2
      src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  23. 34
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  24. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs
  25. 21
      src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  26. 28
      src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs
  27. 7
      src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs
  28. 17
      src/Squidex/app/features/content/pages/content/content-page.component.html
  29. 37
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  30. 2
      src/Squidex/app/features/content/shared/content-item.component.html
  31. 2
      src/Squidex/app/features/content/shared/content-item.component.ts
  32. 8
      src/Squidex/app/features/content/shared/content-status.component.ts
  33. 10
      src/Squidex/app/features/schemas/pages/schema/field.component.html
  34. 24
      src/Squidex/app/shared/services/contents.service.spec.ts
  35. 19
      src/Squidex/app/shared/services/contents.service.ts
  36. 54
      src/Squidex/app/shared/state/contents.state.ts
  37. 66
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  38. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  39. 32
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs
  40. 12
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/OData/ODataQueryTests.cs

4
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -23,8 +23,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
IUpdateableEntityWithLastModifiedBy
{
[BsonRequired]
[BsonElement]
public Guid AppIdId { get; set; }
[BsonElement("AppIdId")]
public Guid IndexedAppId { get; set; }
[BsonRequired]
[BsonElement]

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs

@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
var entity = SimpleMapper.Map(value, new MongoAssetEntity());
entity.Version = newVersion;
entity.AppIdId = value.AppId.Id;
entity.IndexedAppId = value.AppId.Id;
await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert);
}

2
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs

@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors
{
var filters = new List<FilterDefinition<MongoAssetEntity>>
{
Filter.Eq(x => x.AppIdId, appId),
Filter.Eq(x => x.IndexedAppId, appId),
Filter.Eq(x => x.IsDeleted, false)
};

6
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs

@ -43,7 +43,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
if (value.Length < 1000)
{
stringBuilder.Append(" ");
if (stringBuilder.Length > 0)
{
stringBuilder.Append(" ");
}
stringBuilder.Append(text);
}
}

131
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -0,0 +1,131 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.OData.UriParser;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal class MongoContentCollection : MongoRepositoryBase<MongoContentEntity>
{
private readonly string collectionName;
public MongoContentCollection(IMongoDatabase database, string collectionName)
: base(database)
{
this.collectionName = collectionName;
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds));
}
protected override string CollectionName()
{
return collectionName;
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, ODataUriParser odataQuery, Status[] status = null, bool useDraft = false)
{
try
{
var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef, useDraft);
var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator);
var contentCount = Collection.Find(filter).CountAsync();
var contentItems =
Collection.Find(filter)
.ContentTake(odataQuery)
.ContentSkip(odataQuery)
.ContentSort(odataQuery, propertyCalculator)
.Not(x => x.DataText)
.ToListAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
catch (NotSupportedException)
{
throw new ValidationException("This odata operation is not supported.");
}
catch (NotImplementedException)
{
throw new ValidationException("This odata operation is not supported.");
}
catch (MongoQueryException ex)
{
if (ex.Message.Contains("17406"))
{
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items.");
}
else
{
throw;
}
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet<Guid> ids, Status[] status = null)
{
var find =
status != null && status.Length > 0 ?
Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id) && x.IsDeleted != true && status.Contains(x.Status)) :
Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id));
var contentItems = find.Not(x => x.DataText).ToListAsync();
var contentCount = find.CountAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id)
{
var contentEntity =
await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
}
public Task CleanupAsync(Guid id)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, id),
Filter.AnyNe(x => x.ReferencedIdsDeleted, id)),
Update.AddToSet(x => x.ReferencedIdsDeleted, id));
}
}
}

116
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs

@ -0,0 +1,116 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MongoDB.Driver;
using NodaTime;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal sealed class MongoContentDraftCollection : MongoContentCollection
{
public MongoContentDraftCollection(IMongoDatabase database)
: base(database, "State_Content_Draft")
{
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.Id)
.Ascending(x => x.IsDeleted));
await collection.Indexes.CreateOneAsync(
Index
.Text(x => x.DataText)
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Status));
await base.SetupCollectionAsync(collection);
}
public async Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
{
var contentEntities =
await Collection.Find(x => x.IndexedSchemaId == schemaId && ids.Contains(x.Id) && x.IsDeleted != true).Only(x => x.Id)
.ToListAsync();
return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList();
}
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true)
.Not(x => x.DataByIds)
.Not(x => x.DataDraftByIds)
.Not(x => x.DataText)
.ForEachAsync(c =>
{
callback(c);
});
}
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func<Guid, Guid, Task<ISchemaEntity>> getSchema)
{
var contentEntity =
await Collection.Find(x => x.Id == key).Not(x => x.DataText)
.FirstOrDefaultAsync();
if (contentEntity != null)
{
var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId);
contentEntity?.ParseData(schema.SchemaDef);
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version);
}
return (null, EtagVersion.NotFound);
}
public async Task UpsertAsync(MongoContentEntity content, long oldVersion)
{
try
{
content.DataText = content.DataDraftByIds.ToFullText();
await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert);
}
catch (MongoWriteException ex)
{
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingVersion =
await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version)
.FirstOrDefaultAsync();
if (existingVersion != null)
{
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex);
}
}
else
{
throw;
}
}
}
}
}

54
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -21,22 +21,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public sealed class MongoContentEntity : IContentEntity
{
private NamedContentData data;
private NamedContentData pendingData;
private NamedContentData dataDraft;
[BsonId]
[BsonRequired]
[BsonElement]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonRequired]
[BsonElement("ai")]
[BsonElement("_ai")]
[BsonRepresentation(BsonType.String)]
public Guid AppIdId { get; set; }
public Guid IndexedAppId { get; set; }
[BsonRequired]
[BsonElement("si")]
[BsonElement("_si")]
[BsonRepresentation(BsonType.String)]
public Guid SchemaIdId { get; set; }
public Guid IndexedSchemaId { get; set; }
[BsonRequired]
[BsonElement("rf")]
@ -49,38 +49,42 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public List<Guid> ReferencedIdsDeleted { get; set; } = new List<Guid>();
[BsonRequired]
[BsonElement("st")]
[BsonElement("ss")]
[BsonRepresentation(BsonType.String)]
public Status Status { get; set; }
[BsonRequired]
[BsonIgnoreIfDefault]
[BsonElement("do")]
[BsonJson]
public IdContentData DataByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dop")]
[BsonIgnoreIfDefault]
[BsonElement("dd")]
[BsonJson]
public IdContentData PendingDataByIds { get; set; }
public IdContentData DataDraftByIds { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("dt")]
public string DataText { get; set; }
[BsonRequired]
[BsonElement("ai2")]
[BsonElement("ai")]
public NamedId<Guid> AppId { get; set; }
[BsonRequired]
[BsonElement("si2")]
[BsonElement("si")]
public NamedId<Guid> SchemaId { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sdt")]
[BsonElement("st")]
public Status? ScheduledTo { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sda")]
[BsonElement("sa")]
public Instant? ScheduledAt { get; set; }
[BsonIgnoreIfNull]
[BsonElement("sdb")]
[BsonElement("sb")]
public RefToken ScheduledBy { get; set; }
[BsonRequired]
@ -91,18 +95,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonElement("mt")]
public Instant LastModified { get; set; }
[BsonRequired]
[BsonElement("dt")]
public string DataText { get; set; }
[BsonRequired]
[BsonElement("vs")]
public long Version { get; set; }
[BsonRequired]
[BsonIgnoreIfDefault]
[BsonElement("dl")]
public bool IsDeleted { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("pd")]
public bool IsPending { get; set; }
[BsonRequired]
[BsonElement("cb")]
public RefToken CreatedBy { get; set; }
@ -118,18 +122,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
}
[BsonIgnore]
public NamedContentData PendingData
public NamedContentData DataDraft
{
get { return pendingData; }
get { return dataDraft; }
}
public void ParseData(Schema schema)
{
data = DataByIds.ToData(schema, ReferencedIdsDeleted);
if (PendingDataByIds != null)
if (DataDraftByIds != null)
{
pendingData = PendingDataByIds.ToData(schema, ReferencedIdsDeleted);
dataDraft = DataDraftByIds.ToData(schema, ReferencedIdsDeleted);
}
}
}

46
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
internal sealed class MongoContentPublishedCollection : MongoContentCollection
{
public MongoContentPublishedCollection(IMongoDatabase database)
: base(database, "State_Content_Published")
{
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
{
await collection.Indexes.CreateOneAsync(Index.Text(x => x.DataText).Ascending(x => x.IndexedSchemaId));
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.IndexedSchemaId)
.Ascending(x => x.Id));
await base.SetupCollectionAsync(collection);
}
public Task UpsertAsync(MongoContentEntity content)
{
content.DataText = content.DataByIds.ToFullText();
content.DataDraftByIds = null;
return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true });
}
public Task RemoveAsync(Guid id)
{
return Collection.DeleteOneAsync(x => x.Id == id);
}
}
}

140
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.OData.UriParser;
using MongoDB.Driver;
@ -16,146 +15,97 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : MongoRepositoryBase<MongoContentEntity>, IContentRepository
public partial class MongoContentRepository : IContentRepository, IInitializable
{
private readonly IMongoDatabase database;
private readonly IAppProvider appProvider;
private readonly MongoContentDraftCollection contentsDraft;
private readonly MongoContentPublishedCollection contentsPublished;
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider)
: base(database)
{
Guard.NotNull(appProvider, nameof(appProvider));
this.appProvider = appProvider;
}
protected override string CollectionName()
{
return "States_Contents";
contentsDraft = new MongoContentDraftCollection(database);
contentsPublished = new MongoContentPublishedCollection(database);
this.database = database;
}
protected override async Task SetupCollectionAsync(IMongoCollection<MongoContentEntity> collection)
public void Initialize()
{
await collection.Indexes.TryDropOneAsync("si_1_st_1_dl_1_dt_text");
await collection.Indexes.CreateOneAsync(
Index
.Text(x => x.DataText)
.Ascending(x => x.SchemaIdId)
.Ascending(x => x.Status)
.Ascending(x => x.IsDeleted));
await collection.Indexes.CreateOneAsync(
Index
.Ascending(x => x.SchemaIdId)
.Ascending(x => x.Id)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Status));
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds));
contentsDraft.Initialize();
contentsPublished.Initialize();
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery)
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery)
{
try
if (RequiresPublished(status))
{
var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef);
var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator);
var contentCount = Collection.Find(filter).CountAsync();
var contentItems =
Collection.Find(filter)
.ContentTake(odataQuery)
.ContentSkip(odataQuery)
.ContentSort(odataQuery, propertyCalculator)
.ToListAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
{
entity.ParseData(schema.SchemaDef);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
return contentsPublished.QueryAsync(app, schema, odataQuery);
}
catch (NotSupportedException)
else
{
throw new ValidationException("This odata operation is not supported.");
return contentsDraft.QueryAsync(app, schema, odataQuery, status, true);
}
catch (NotImplementedException)
}
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids)
{
if (RequiresPublished(status))
{
throw new ValidationException("This odata operation is not supported.");
return contentsPublished.QueryAsync(app, schema, ids);
}
catch (MongoQueryException ex)
else
{
if (ex.Message.Contains("17406"))
{
throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items.");
}
else
{
throw;
}
return contentsDraft.QueryAsync(app, schema, ids, status);
}
}
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids)
public Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id)
{
var find = Collection.Find(x => x.SchemaIdId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status));
var contentItems = find.ToListAsync();
var contentCount = find.CountAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result)
if (RequiresPublished(status))
{
entity.ParseData(schema.SchemaDef);
return contentsPublished.FindContentAsync(app, schema, id);
}
else
{
return contentsPublished.FindContentAsync(app, schema, id);
}
return ResultList.Create<IContentEntity>(contentItems.Result, contentCount.Result);
}
public async Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
public Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids)
{
var contentEntities =
await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id)
.ToListAsync();
return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList();
return contentsDraft.QueryNotFoundAsync(appId, schemaId, ids);
}
public async Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id)
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
{
var contentEntity =
await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false)
.FirstOrDefaultAsync();
contentEntity?.ParseData(schema.SchemaDef);
return contentEntity;
return contentsDraft.QueryScheduledWithoutDataAsync(now, callback);
}
public Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback)
public Task ClearAsync()
{
return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false)
.ForEachAsync(c =>
{
callback(c);
});
return Task.WhenAll(
contentsDraft.ClearAsync(),
contentsPublished.ClearAsync());
}
public Task DeleteArchiveAsync()
{
return Database.DropCollectionAsync("States_Contents_Archive");
return database.DropCollectionAsync("States_Contents_Archive");
}
private static bool RequiresPublished(Status[] status)
{
return status?.Length == 1 && status[0] == Status.Published;
}
}
}

16
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs

@ -33,20 +33,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
protected Task On(AssetDeleted @event)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, @event.AssetId),
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.AssetId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.AssetId));
return Task.WhenAll(
contentsDraft.CleanupAsync(@event.AssetId),
contentsPublished.CleanupAsync(@event.AssetId));
}
protected Task On(ContentDeleted @event)
{
return Collection.UpdateManyAsync(
Filter.And(
Filter.AnyEq(x => x.ReferencedIds, @event.ContentId),
Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)),
Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId));
return Task.WhenAll(
contentsDraft.CleanupAsync(@event.ContentId),
contentsPublished.CleanupAsync(@event.ContentId));
}
Task IEventConsumer.ClearAsync()

70
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs

@ -7,12 +7,11 @@
using System;
using System.Threading.Tasks;
using MongoDB.Driver;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
@ -20,27 +19,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public partial class MongoContentRepository : ISnapshotStore<ContentState, Guid>
{
Task ISnapshotStore<ContentState, Guid>.ReadAllAsync(Func<ContentState, long, Task> callback)
public Task<(ContentState Value, long Version)> ReadAsync(Guid key)
{
throw new NotSupportedException();
}
public async Task<(ContentState Value, long Version)> ReadAsync(Guid key)
{
var contentEntity =
await Collection.Find(x => x.Id == key).SortByDescending(x => x.Version)
.FirstOrDefaultAsync();
if (contentEntity != null)
{
var schema = await GetSchemaAsync(contentEntity.AppIdId, contentEntity.SchemaIdId);
contentEntity?.ParseData(schema.SchemaDef);
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version);
}
return (null, EtagVersion.NotFound);
return contentsDraft.ReadAsync(key, GetSchemaAsync);
}
public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion)
@ -52,42 +33,28 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id);
var idData = value.Data?.ToIdModel(schema.SchemaDef, true);
var idData = value.Data.ToIdModel(schema.SchemaDef, true);
var document = SimpleMapper.Map(value, new MongoContentEntity
var content = SimpleMapper.Map(value, new MongoContentEntity
{
AppIdId = value.AppId.Id,
SchemaIdId = value.SchemaId.Id,
IsDeleted = value.IsDeleted,
DataText = idData?.ToFullText(),
DataByIds = idData,
PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true),
ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef),
DataDraftByIds = value.DataDraft?.ToIdModel(schema.SchemaDef, true),
IsDeleted = value.IsDeleted,
IndexedAppId = value.AppId.Id,
IndexedSchemaId = value.SchemaId.Id,
ReferencedIds = idData.ToReferencedIds(schema.SchemaDef),
Version = newVersion
});
document.Version = newVersion;
await contentsDraft.UpsertAsync(content, oldVersion);
try
if (value.Status == Status.Published && !value.IsDeleted)
{
await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, document, Upsert);
await contentsPublished.UpsertAsync(content);
}
catch (MongoWriteException ex)
else
{
if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingVersion =
await Collection.Find(x => x.Id == key).Only(x => x.Id, x => x.Version)
.FirstOrDefaultAsync();
if (existingVersion != null)
{
throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex);
}
}
else
{
throw;
}
await contentsPublished.RemoveAsync(content.Id);
}
}
@ -102,5 +69,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
return schema;
}
Task ISnapshotStore<ContentState, Guid>.ReadAllAsync(Func<ContentState, long, Task> callback)
{
throw new NotSupportedException();
}
}
}

33
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs

@ -27,12 +27,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
typeof(MongoContentEntity).GetProperties()
.ToDictionary(x => x.Name, x => x.GetCustomAttribute<BsonElementAttribute>()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase);
static FindExtensions()
{
PropertyMap["Data"] = "do";
}
public static PropertyCalculator CreatePropertyCalculator(Schema schema)
public static PropertyCalculator CreatePropertyCalculator(Schema schema, bool useDraft)
{
return propertyNames =>
{
@ -50,7 +45,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
if (propertyNames.Length > 0)
{
propertyNames[0] = PropertyMap[propertyNames[0]];
if (propertyNames[0].Equals("Data", StringComparison.CurrentCultureIgnoreCase))
{
if (useDraft)
{
propertyNames[0] = "dd";
}
else
{
propertyNames[0] = "do";
}
}
else
{
propertyNames[0] = PropertyMap[propertyNames[0]];
}
}
var propertyName = string.Join(".", propertyNames);
@ -80,11 +89,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
{
var filters = new List<FilterDefinition<MongoContentEntity>>
{
Filter.Eq(x => x.SchemaIdId, schemaId),
Filter.In(x => x.Status, status),
Filter.Eq(x => x.IsDeleted, false)
Filter.Eq(x => x.IndexedSchemaId, schemaId),
};
if (status != null)
{
filters.Add(Filter.Ne(x => x.IsDeleted, true));
filters.Add(Filter.In(x => x.Status, status));
}
var filter = query.BuildFilter<MongoContentEntity>(propertyCalculator);
if (filter.Filter != null)

2
src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs

@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
public abstract class ContentDataCommand : ContentCommand
{
public NamedContentData Data { get; set; }
public bool AsDraft { get; set; }
}
}

1
src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs

@ -9,6 +9,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class PatchContent : ContentDataCommand
{
public bool AsProposal { get; set; }
}
}

1
src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs

@ -9,6 +9,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class UpdateContent : ContentDataCommand
{
public bool AsProposal { get; set; }
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -42,7 +42,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
public NamedContentData Data { get; set; }
public NamedContentData PendingData { get; set; }
public NamedContentData DataDraft { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{

76
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
GuardContent.CanUpdate(c);
return UpdateContentAsync(c, c.Data, "Update", c.AsProposal);
return UpdateAsync(c, x => c.Data, false);
});
case PatchContent patchContent:
@ -92,21 +92,32 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
GuardContent.CanPatch(c);
return UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal);
return UpdateAsync(c, c.Data.MergeInto, true);
});
case ChangeContentStatus changeContentStatus:
return UpdateReturnAsync(changeContentStatus, c =>
return UpdateAsync(changeContentStatus, async c =>
{
GuardContent.CanChangeContentStatus(Snapshot.PendingData, Snapshot.Status, c);
GuardContent.CanChangeContentStatus(Snapshot.IsPending, Snapshot.Status, c);
if (Snapshot.PendingData != null)
if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published)
{
return UpdateContentAsync(c, Snapshot.PendingData, "Update", false);
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, Snapshot.DataDraft, Snapshot.Data);
ConfirmChanges(c);
}
else
{
return ChangeStatusAsync(c);
if (!c.DueTime.HasValue)
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content.");
await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data);
}
ChangeStatus(c);
}
});
@ -125,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.PendingData, c);
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
});
@ -135,41 +146,43 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<object> ChangeStatusAsync(ChangeContentStatus c)
private async Task<object> UpdateAsync(ContentDataCommand c, Func<NamedContentData, NamedContentData> newDataFunc, bool partial)
{
if (!c.DueTime.HasValue)
{
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content.");
var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data);
}
ChangeStatus(c);
var currentData =
isProposal ?
Snapshot.DataDraft :
Snapshot.Data;
return new EntitySavedResult(NewVersion);
}
private async Task<object> UpdateContentAsync(ContentCommand command, NamedContentData data, string operation, bool asProposal)
{
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
var newData = newDataFunc(currentData);
if (!Snapshot.Data.Equals(data))
if (!currentData.Equals(newData))
{
await operationContext.ValidateAsync(data);
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content.");
if (asProposal)
if (partial)
{
ProposeUpdate(command, data);
await ctx.ValidatePartialAsync(c.Data);
}
else
{
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", command, data, Snapshot.Data);
await ctx.ValidateAsync(c.Data);
}
Update(command, data);
if (isProposal)
{
ProposeUpdate(c, newData);
}
else
{
newData = await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, newData, Snapshot.Data);
Update(c, newData);
}
}
return new ContentDataChangedResult(data, NewVersion);
return new ContentDataChangedResult(newData, NewVersion);
}
public void Create(CreateContent command)
@ -182,6 +195,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public void ConfirmChanges(ChangeContentStatus command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentChangesConfirmed()));
}
public void DiscardChanges(DiscardChanges command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));

7
src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs

@ -69,6 +69,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
public Task ValidatePartialAsync(NamedContentData data)
{
var ctx = CreateValidationContext();
return data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
public Task<NamedContentData> ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{
var ctx = CreateScriptContext(operation, command, data, oldData);

19
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -70,10 +70,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
var isFrontendClient = IsFrontendClient(user);
var isVersioned = version > EtagVersion.Empty;
var parsedStatus = isFrontendClient ? new[] { Status.Published } : null;
var content =
isVersioned ?
await FindContentByVersionAsync(id, version) :
await FindContentAsync(app, id, schema);
await FindContentAsync(app, id, parsedStatus, schema);
if (content == null || (content.Status != Status.Published && !isFrontendClient) || content.SchemaId.Id != schema.Id)
{
@ -144,12 +146,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var result = SimpleMapper.Map(content, new ContentEntity());
if (!isFrontendClient && isScripting)
if (result.Data != null)
{
result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText);
if (!isFrontendClient && isScripting)
{
result.Data = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText);
}
result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
}
result.Data = result.Data.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
result.DataDraft = result.DataDraft.ToApiModel(schema.SchemaDef, app.LanguagesConfig, isFrontendClient, isTypeChecking);
yield return result;
}
@ -222,9 +229,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentVersionLoader.LoadAsync(id, version);
}
private Task<IContentEntity> FindContentAsync(IAppEntity app, Guid id, ISchemaEntity schema)
private Task<IContentEntity> FindContentAsync(IAppEntity app, Guid id, Status[] status, ISchemaEntity schema)
{
return contentRepository.FindContentAsync(app, schema, id);
return contentRepository.FindContentAsync(app, schema, status, id);
}
private static bool IsFrontendClient(ClaimsPrincipal user)

9
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -53,30 +53,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
});
}
public static void CanDiscardChanges(NamedContentData pendingData, DiscardChanges command)
public static void CanDiscardChanges(bool isPending, DiscardChanges command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot discard pending changes.", error =>
{
if (pendingData == null)
if (!isPending)
{
error(new ValidationError("The content has no pending changes."));
}
});
}
public static void CanChangeContentStatus(NamedContentData pendingData, Status status, ChangeContentStatus command)
public static void CanChangeContentStatus(bool isPending, Status status, ChangeContentStatus command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot change status.", error =>
{
var isAllowedPendingUpdate =
command.DueTime == null &&
status == command.Status &&
status == Status.Published &&
pendingData != null;
isPending;
if (!StatusFlow.Exists(command.Status) || (!StatusFlow.CanChange(status, command.Status) && !isAllowedPendingUpdate))
{

4
src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs

@ -33,6 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
NamedContentData Data { get; }
NamedContentData PendingData { get; }
NamedContentData DataDraft { get; }
bool IsPending { get; }
}
}

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

@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
Task<IReadOnlyList<Guid>> QueryNotFoundAsync(Guid appId, Guid schemaId, IList<Guid> ids);
Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id);
Task<IContentEntity> FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id);
Task QueryScheduledWithoutDataAsync(Instant now, Func<IContentEntity, Task> callback);
}

34
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
public NamedContentData Data { get; set; }
[JsonProperty]
public NamedContentData PendingData { get; set; }
public NamedContentData DataDraft { get; set; }
[JsonProperty]
public Status Status { get; set; }
@ -44,29 +44,44 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
[JsonProperty]
public RefToken ScheduledBy { get; set; }
[JsonProperty]
public bool IsPending { get; set; }
[JsonProperty]
public bool IsDeleted { get; set; }
protected void On(ContentCreated @event)
{
SimpleMapper.Map(@event, this);
DataDraft = @event.Data;
}
protected void On(ContentUpdated @event)
{
Data = @event.Data;
DataDraft = @event.Data;
}
protected void On(ContentUpdateProposed @event)
{
PendingData = @event.Data;
DataDraft = @event.Data;
IsPending = true;
}
protected void On(ContentChangesDiscarded @event)
{
PendingData = null;
DataDraft = Data;
IsPending = false;
}
protected void On(ContentUpdated @event)
protected void On(ContentChangesConfirmed @event)
{
PendingData = null;
Data = DataDraft;
Data = @event.Data;
IsPending = false;
}
protected void On(ContentStatusScheduled @event)
@ -83,6 +98,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
ScheduledAt = null;
ScheduledBy = null;
ScheduledTo = null;
if (@event.Status == Status.Published)
{
Data = DataDraft;
IsPending = false;
}
}
protected void On(ContentDeleted @event)

16
src/Squidex.Domain.Apps.Events/Contents/ContentChangesConfirmed.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Contents
{
[EventType(nameof(ContentChangesConfirmed))]
public sealed class ContentChangesConfirmed : ContentEvent
{
}
}

21
src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -74,6 +74,27 @@ namespace Squidex.Infrastructure.MongoDb
return find.Project<BsonDocument>(Builders<TDocument>.Projection.Include(include1).Include(include2).Include(include3));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude1,
Expression<Func<TDocument, object>> exclude2)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude1).Exclude(exclude2));
}
public static IFindFluent<TDocument, TDocument> Not<TDocument>(this IFindFluent<TDocument, TDocument> find,
Expression<Func<TDocument, object>> exclude1,
Expression<Func<TDocument, object>> exclude2,
Expression<Func<TDocument, object>> exclude3)
{
return find.Project<TDocument>(Builders<TDocument>.Projection.Exclude(exclude1).Exclude(exclude2).Exclude(exclude3));
}
public static async Task UpsertVersionedAsync<T, TKey>(this IMongoCollection<T> collection, TKey key, long oldVersion, long newVersion, Func<UpdateDefinition<T>, UpdateDefinition<T>> updater) where T : IVersionedEntity<TKey>
{
try

28
src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs

@ -119,7 +119,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = new ContentsDto
{
Total = result.Total,
Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data })).ToArray()
Items = result.Take(200).Select(item => SimpleMapper.Map(item, new ContentDto { Data = item.Data, DataDraft = item.DataDraft })).ToArray()
};
Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id));
@ -148,10 +148,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
var content = await contentQuery.FindContentAsync(App, name, User, id);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data });
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft });
Response.Headers["ETag"] = entity.Version.ToString();
Response.Headers["Surrogate-Key"] = entity.Id.ToString();
Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = content.Id.ToString();
return Ok(response);
}
@ -177,9 +177,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version)
{
var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id, version);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data });
var content = await contentQuery.FindContentAsync(App, name, User, id, version);
var response = SimpleMapper.Map(content, new ContentDto { Data = content.Data, DataDraft = content.DataDraft });
Response.Headers["ETag"] = content.Version.ToString();
Response.Headers["Surrogate-Key"] = content.Id.ToString();
@ -227,7 +227,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to update.</param>
/// <param name="request">The full data for the content item.</param>
/// <param name="asProposal">Indicates whether the update is a proposal.</param>
/// <param name="asDraft">Indicates whether the update is a proposal.</param>
/// <returns>
/// 200 => Content updated.
/// 404 => Content, schema or app not found.
@ -240,11 +240,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut]
[Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal };
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();
@ -260,7 +260,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to patch.</param>
/// <param name="request">The patch for the content item.</param>
/// <param name="asProposal">Indicates whether the patch is a proposal.</param>
/// <param name="asDraft">Indicates whether the patch is a proposal.</param>
/// <returns>
/// 200 => Content patched.
/// 404 => Content, schema or app not found.
@ -273,11 +273,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPatch]
[Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal };
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>();
@ -426,7 +426,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DiscardChanges(string app, string name, Guid id)
{
await contentQuery.FindSchemaAsync(App, name);
await contentQuery.ThrowIfSchemaNotExistsAsync(App, name);
var command = new DiscardChanges { ContentId = id };

7
src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs

@ -43,7 +43,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// <summary>
/// The pending changes of the content item.
/// </summary>
public object PendingData { get; set; }
public object DataDraft { get; set; }
/// <summary>
/// Indicates if the draft data is pending.
/// </summary>
public bool IsPending { get; set; }
/// <summary>
/// The scheduled status.

17
src/Squidex/app/features/content/pages/content/content-page.component.html

@ -20,18 +20,31 @@
<ng-container menu>
<ng-container *ngIf="!content; else notNew">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
<button type="button" class="btn btn-secondary" (click)="save()">
Save as Draft
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save and Publish
</button>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut>
</ng-container>
<ng-template #notNew>
<span class="btn btn btn-outline-secondary">
<sqx-content-status
[status]="content.status"
[scheduledTo]="content.scheduledTo"
[scheduledAt]="content.scheduledAt"
[isPending]="content.isPending">
</sqx-content-status>
</span>
<ng-container *ngIf="content.status !== 'Archived'">
<button type="button" class="btn btn-secondary" (click)="saveAsProposal()">
Save as Proposal
</button>
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>

37
src/Squidex/app/features/content/pages/content/content-page.component.ts

@ -87,7 +87,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
.subscribe(content => {
this.content = content;
this.loadContent(content.data);
this.loadContent(content.dataDraft);
});
this.contentVersionSelectedSubscription =
@ -106,14 +106,18 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
}
public saveAndPublish() {
this.saveContent(true);
this.saveContent(true, false);
}
public saveAsDraft() {
this.saveContent(false);
public saveAsProposal() {
this.saveContent(false, true);
}
private saveContent(publish: boolean) {
public save() {
this.saveContent(false, false);
}
private saveContent(publish: boolean, asProposal: boolean) {
if (this.content && this.content.status === 'Archived') {
return;
}
@ -122,12 +126,21 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (value) {
if (this.content) {
this.contentsState.update(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
if (asProposal) {
this.contentsState.proposeUpdate(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
} else {
this.contentsState.update(this.content, value)
.subscribe(dto => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
}
} else {
this.contentsState.create(value, publish)
.subscribe(dto => {
@ -168,7 +181,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (this.contentVersion) {
this.contentVersion = null;
this.loadContent(this.content.data);
this.loadContent(this.content.dataDraft);
}
}
}

2
src/Squidex/app/features/content/shared/content-item.component.html

@ -60,7 +60,7 @@
[status]="content.status"
[scheduledTo]="content.scheduledTo"
[scheduledAt]="content.scheduledAt"
[hasPendingChanges]="content.pendingData">
[isPending]="content.isPending">
</sqx-content-status>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small>

2
src/Squidex/app/features/content/shared/content-item.component.ts

@ -137,7 +137,7 @@ export class ContentItemComponent implements OnChanges {
}
private getRawValue(field: FieldDto): any {
const contentField = this.content.displayData[field.name];
const contentField = this.content.dataDraft[field.name];
if (contentField) {
if (field.isLocalizable) {

8
src/Squidex/app/features/content/shared/content-status.component.ts

@ -5,9 +5,9 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { DateTime } from 'shared';
import { DateTime } from '@app/shared';
@Component({
selector: 'sqx-content-status',
@ -26,13 +26,13 @@ export class ContentStatusComponent {
public scheduledAt?: DateTime;
@Input()
public hasPendingChanges: any;
public isPending: any;
@Input()
public showLabel = false;
public get displayStatus() {
return !!this.hasPendingChanges ? 'Pending' : this.status;
return !!this.isPending ? 'Pending' : this.status;
}
}

10
src/Squidex/app/features/schemas/pages/schema/field.component.html

@ -28,22 +28,22 @@
</button>
<div class="dropdown-menu" *sqxModalView="dropdown" [sqxModalTarget]="optionsButton" @fade>
<a class="dropdown-item" (click)="enableField()" *ngIf="field.isDisabled" [class.disabled]="field.isLocked">
Enable
Enable in UI
</a>
<a class="dropdown-item" (click)="disableField()" *ngIf="!field.isDisabled" [class.disabled]="field.isLocked">
Disable
Disable in UI
</a>
<a class="dropdown-item" (click)="hideField()" *ngIf="!field.isHidden" [class.disabled]="field.isLocked">
Hide
Hide in API
</a>
<a class="dropdown-item" (click)="showField()" *ngIf="field.isHidden" [class.disabled]="field.isLocked">
Show
Show in API
</a>
<a class="dropdown-item" *ngIf="!field.isLocked"
(sqxConfirmClick)="lockField()"
confirmTitle="Lock field"
confirmText="Do you really want to lock the field? Lock fields cannot be deleted or changed.">
Lock
Lock and prevent changes
</a>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="field.isLocked"
(sqxConfirmClick)="deleteField()"

24
src/Squidex/app/shared/services/contents.service.spec.ts

@ -65,8 +65,10 @@ describe('ContentsService', () => {
scheduledTo: 'Draft',
scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10',
isPending: true,
version: 11,
data: {}
data: {},
dataDraft: {}
},
{
id: 'id2',
@ -76,7 +78,8 @@ describe('ContentsService', () => {
lastModified: '2017-10-12T10:10',
lastModifiedBy: 'LastModifiedBy2',
version: 22,
data: {}
data: {},
dataDraft: {}
}
]
});
@ -89,8 +92,9 @@ describe('ContentsService', () => {
'Draft',
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
true,
{},
{},
undefined,
new Version('11')),
new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'),
@ -98,8 +102,9 @@ describe('ContentsService', () => {
null,
null,
null,
false,
{},
{},
undefined,
new Version('22'))
]));
}));
@ -167,7 +172,8 @@ describe('ContentsService', () => {
scheduledTo: 'Draft',
scheduledBy: 'Scheduler1',
scheduledAt: '2018-12-12T10:10',
data: {}
data: {},
dataDraft: {}
}, {
headers: {
etag: '2'
@ -181,8 +187,9 @@ describe('ContentsService', () => {
'Draft',
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
true,
{},
{},
undefined,
new Version('2')));
}));
@ -223,8 +230,9 @@ describe('ContentsService', () => {
null,
null,
null,
{},
true,
undefined,
{},
new Version('2')));
}));
@ -256,7 +264,7 @@ describe('ContentsService', () => {
contentsService.putContent('my-app', 'my-schema', 'content1', dto, true, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?asProposal=true');
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?asDraft=true');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value);

19
src/Squidex/app/shared/services/contents.service.ts

@ -29,8 +29,6 @@ export class ContentsDto {
}
export class ContentDto {
public displayData: any;
constructor(
public readonly id: string,
public readonly status: string,
@ -41,11 +39,11 @@ export class ContentDto {
public readonly scheduledTo: string | null,
public readonly scheduledBy: string | null,
public readonly scheduledAt: DateTime | null,
public readonly isPending: boolean,
public readonly data: any,
public readonly pendingData: any,
public readonly dataDraft: any,
public readonly version: Version
) {
this.displayData = pendingData || data;
}
}
@ -108,8 +106,9 @@ export class ContentsService {
item.scheduledTo || null,
item.scheduledBy || null,
item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null,
item.isPending,
item.data,
item.pendingData,
item.dataDraft,
new Version(item.version.toString()));
}));
})
@ -133,8 +132,9 @@ export class ContentsService {
body.scheduledTo || null,
body.scheduledBy || null,
body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null,
body.isPending,
body.data,
body.pendingData,
body.dataDraft,
response.version);
})
.pretifyError('Failed to load content. Please reload.');
@ -167,8 +167,9 @@ export class ContentsService {
null,
null,
null,
true,
null,
body.data,
body.pendingData,
response.version);
})
.do(content => {
@ -177,8 +178,8 @@ export class ContentsService {
.pretifyError('Failed to create content. Please reload.');
}
public putContent(appName: string, schemaName: string, id: string, dto: any, asProposal: boolean, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asProposal=${asProposal}`);
public putContent(appName: string, schemaName: string, id: string, dto: any, asDraft: boolean, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asDraft=${asDraft}`);
return HTTP.putVersioned(this.http, url, dto, version)
.map(response => {

54
src/Squidex/app/shared/state/contents.state.ts

@ -289,7 +289,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
}
public update(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, content.version)
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, false, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
@ -298,10 +298,30 @@ export abstract class ContentsStateBase extends State<Snapshot> {
.notify(this.dialogs);
}
public proposeUpdate(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, true, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(updateDataDraft(content, dto.payload, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public revertProposal(content: ContentDto, now?: DateTime): Observable<any> {
return this.contentsService.discardChanges(this.appName, this.schemaName, content.id, content.version)
.do(dto => {
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(revertDataDraft(content, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public patch(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
this.dialogs.notifyInfo('Content updated successfully.');
this.replaceContent(updateData(content, dto.payload, this.user, dto.version, now));
})
@ -400,5 +420,35 @@ const updateData = (content: ContentDto, data: any, user: string, version: Versi
content.scheduledTo,
content.scheduledBy,
content.scheduledAt,
content.isPending,
data,
data,
version);
const updateDataDraft = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduledTo,
content.scheduledBy,
content.scheduledAt,
true,
content.data,
data,
version);
const revertDataDraft = (content: ContentDto, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduledTo,
content.scheduledBy,
content.scheduledAt,
false,
content.data,
content.data,
version);

66
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -94,17 +94,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => schema.ScriptUpdate).Returns("<update-script>");
A.CallTo(() => schema.ScriptDelete).Returns("<delete-script>");
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, data)), A<string>.Ignored))
.Returns(data);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, invalidData)), A<string>.Ignored))
.Returns(invalidData);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, patch)), A<string>.Ignored))
.Returns(patch);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.That.Matches(x => ReferenceEquals(x.Data, otherData)), A<string>.Ignored))
.Returns(otherData);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, A<string>.Ignored))
.ReturnsLazily(x => x.GetArgument<ScriptContext>(0).Data);
patched = patch.MergeInto(data);
@ -190,6 +181,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened();
}
[Fact]
public async Task Update_should_create_proposal_events_and_update_state()
{
var command = new UpdateContent { Data = otherData, AsDraft = true };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 2));
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentUpdateProposed { Data = otherData })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened();
}
[Fact]
public async Task Update_should_not_create_event_for_same_data()
{
@ -207,7 +219,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustHaveHappened();
.MustNotHaveHappened();
}
[Fact]
@ -229,7 +241,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 1));
result.ShouldBeEquivalent(new ContentDataChangedResult(patched, 1));
LastEvents
.ShouldHaveSameEvents(
@ -240,6 +252,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened();
}
[Fact]
public async Task Patch_should_create_proposal_events_and_update_state()
{
var command = new PatchContent { Data = patch, AsDraft = true };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new ContentDataChangedResult(otherData, 2));
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentUpdateProposed { Data = patched })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustNotHaveHappened();
}
[Fact]
public async Task Patch_should_not_create_event_for_same_data()
{
@ -257,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<update-script>"))
.MustHaveHappened();
.MustNotHaveHappened();
}
[Fact]
@ -345,6 +378,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return sut.ExecuteAsync(CreateContentCommand(new DeleteContent()));
}
private Task ExecutePublishAsync()
{
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published }));
}
protected T CreateContentEvent<T>(T @event) where T : ContentEvent
{
@event.ContentId = contentId;

4
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId))
A.CallTo(() => contentRepository.FindContentAsync(app, schema, null, contentId))
.Returns(content);
A.CallTo(() => schema.ScriptQuery)
@ -140,7 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(schema);
A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId))
A.CallTo(() => contentRepository.FindContentAsync(app, schema, new[] { Status.Published }, contentId))
.Returns((IContentEntity)null);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId));

32
tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{
var command = new ChangeContentStatus { Status = (Status)10 };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command));
}
[Fact]
@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{
var command = new ChangeContentStatus { Status = Status.Published };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Archived, command));
}
[Fact]
@ -87,23 +87,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Draft, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Draft, command));
}
[Fact]
public void CanChangeContentStatus_should_throw_exception_republishing_without_pending_changes()
public void CanChangeContentStatus_should_throw_exception_if_publishing_without_pending_changes()
{
var command = new ChangeContentStatus { Status = Status.Published };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Published, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(false, Status.Published, command));
}
[Fact]
public void CanChangeContentStatus_should_not_throw_exception_republishing_with_pending_changes()
public void CanChangeContentStatus_should_not_throw_exception_if_publishing_with_pending_changes()
{
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(new NamedContentData(), Status.Published, command);
GuardContent.CanChangeContentStatus(true, Status.Published, command);
}
[Fact]
@ -111,7 +111,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(null, Status.Draft, command);
GuardContent.CanChangeContentStatus(false, Status.Draft, command);
}
[Fact]
public void CanDiscardChanges_should_throw_exception_if_pending()
{
var command = new DiscardChanges();
Assert.Throws<ValidationException>(() => GuardContent.CanDiscardChanges(false, command));
}
[Fact]
public void CanDiscardChanges_should_not_throw_exception_if_pending()
{
var command = new DiscardChanges();
GuardContent.CanDiscardChanges(true, command);
}
[Fact]

12
tests/Squidex.Domain.Apps.Entities.Tests/Contents/OData/ODataQueryTests.cs

@ -30,9 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
{
public class ODataQueryTests
{
private static readonly IBsonSerializerRegistry Registry = BsonSerializer.SerializerRegistry;
private static readonly IBsonSerializer<MongoContentEntity> Serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoContentEntity>();
private readonly Schema schemaDef;
private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry;
private readonly IBsonSerializer<MongoContentEntity> serializer = BsonSerializer.SerializerRegistry.GetSerializer<MongoContentEntity>();
private readonly IEdmModel edmModel;
private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.EN, Language.DE);
@ -417,10 +417,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
A.CallTo(() => cursor.Sort(A<SortDefinition<MongoContentEntity>>.Ignored))
.Invokes((SortDefinition<MongoContentEntity> sortDefinition) =>
{
i = sortDefinition.Render(serializer, registry).ToString();
i = sortDefinition.Render(Serializer, Registry).ToString();
});
cursor.ContentSort(parser, FindExtensions.CreatePropertyCalculator(schemaDef));
cursor.ContentSort(parser, FindExtensions.CreatePropertyCalculator(schemaDef, false));
return i;
}
@ -430,8 +430,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.OData
var parser = edmModel.ParseQuery(value);
var query =
parser.BuildFilter<MongoContentEntity>(FindExtensions.CreatePropertyCalculator(schemaDef))
.Filter.Render(serializer, registry).ToString();
parser.BuildFilter<MongoContentEntity>(FindExtensions.CreatePropertyCalculator(schemaDef, false))
.Filter.Render(Serializer, Registry).ToString();
return query;
}

Loading…
Cancel
Save