Browse Source

Content proposals.

pull/285/head
Sebastian Stehle 8 years ago
parent
commit
f294d26fd8
  1. 17
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  2. 1
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  3. 8
      src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs
  4. 56
      src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  5. 13
      src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs
  6. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs
  7. 1
      src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs
  8. 2
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  9. 148
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  10. 22
      src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs
  11. 76
      src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs
  12. 23
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  13. 2
      src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs
  14. 18
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  15. 26
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs
  16. 30
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs
  17. 16
      src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs
  18. 18
      src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs
  19. 70
      src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs
  20. 5
      src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs
  21. 6
      src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs
  22. 2
      src/Squidex/app/features/content/declarations.ts
  23. 2
      src/Squidex/app/features/content/module.ts
  24. 36
      src/Squidex/app/features/content/pages/content/content-page.component.html
  25. 18
      src/Squidex/app/features/content/pages/content/content-page.component.scss
  26. 51
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  27. 2
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  28. 21
      src/Squidex/app/features/content/shared/content-item.component.html
  29. 28
      src/Squidex/app/features/content/shared/content-item.component.scss
  30. 2
      src/Squidex/app/features/content/shared/content-item.component.ts
  31. 17
      src/Squidex/app/features/content/shared/content-status.component.html
  32. 34
      src/Squidex/app/features/content/shared/content-status.component.scss
  33. 38
      src/Squidex/app/features/content/shared/content-status.component.ts
  34. 16
      src/Squidex/app/shared/services/contents.service.spec.ts
  35. 67
      src/Squidex/app/shared/services/contents.service.ts
  36. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs
  37. 26
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/Guard/GuardContentTests.cs

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

@ -21,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public sealed class MongoContentEntity : IContentEntity public sealed class MongoContentEntity : IContentEntity
{ {
private NamedContentData data; private NamedContentData data;
private NamedContentData pendingData;
[BsonId] [BsonId]
[BsonRequired] [BsonRequired]
@ -62,6 +63,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonJson] [BsonJson]
public IdContentData DataByIds { get; set; } public IdContentData DataByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dop")]
[BsonJson]
public IdContentData PendingDataByIds { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement("ai2")] [BsonElement("ai2")]
public NamedId<Guid> AppId { get; set; } public NamedId<Guid> AppId { get; set; }
@ -116,9 +122,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
get { return data; } get { return data; }
} }
[BsonIgnore]
public NamedContentData PendingData
{
get { return pendingData; }
}
public void ParseData(Schema schema) public void ParseData(Schema schema)
{ {
data = DataByIds.ToData(schema, ReferencedIdsDeleted); data = DataByIds.ToData(schema, ReferencedIdsDeleted);
if (PendingDataByIds != null)
{
pendingData = PendingDataByIds.ToData(schema, ReferencedIdsDeleted);
}
} }
} }
} }

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

@ -59,6 +59,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
DocumentId = key.ToString(), DocumentId = key.ToString(),
DataText = idData?.ToFullText(), DataText = idData?.ToFullText(),
DataByIds = idData, DataByIds = idData,
PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true),
ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef), ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef),
}); });

8
src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs

@ -23,19 +23,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
public Guid Id public Guid Id
{ {
get { return inner.Id; } get { return inner.Id; }
set { }
} }
public Instant Created public Instant Created
{ {
get { return inner.Created; } get { return inner.Created; }
set { }
} }
public Instant LastModified public Instant LastModified
{ {
get { return inner.LastModified; } get { return inner.LastModified; }
set { }
} }
public RefToken Actor public RefToken Actor
@ -58,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
get { return inner.Channel; } get { return inner.Channel; }
} }
public string EventType
{
get { return inner.Message; }
}
public string Message public string Message
{ {
get { return message.Value; } get { return message.Value; }

56
src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs

@ -20,34 +20,43 @@ namespace Squidex.Domain.Apps.Entities.Apps
: base(typeNameRegistry) : base(typeNameRegistry)
{ {
AddEventMessage<AppContributorAssigned>( AddEventMessage<AppContributorAssigned>(
"assigned {user:[Contributor]} as [Permission]"); "assigned {user:[Contributor]} as [Permission].");
AddEventMessage<AppContributorRemoved>( AddEventMessage<AppContributorRemoved>(
"removed {user:[Contributor]} from app"); "removed {user:[Contributor]}.");
AddEventMessage<AppClientAttached>( AddEventMessage<AppClientAttached>(
"added client {[Id]} to app"); "added client {[Id]}.");
AddEventMessage<AppClientRevoked>( AddEventMessage<AppClientRevoked>(
"revoked client {[Id]}"); "revoked client {[Id]}.");
AddEventMessage<AppClientUpdated>( AddEventMessage<AppClientUpdated>(
"updated client {[Id]}"); "updated client {[Id]}.");
AddEventMessage<AppClientRenamed>( AddEventMessage<AppClientRenamed>(
"renamed client {[Id]} to {[Name]}"); "renamed client {[Id]} to {[Name]}.");
AddEventMessage<AppLanguageAdded>( AddEventMessage<AppLanguageAdded>(
"added language {[Language]}"); "added language {[Language]}.");
AddEventMessage<AppLanguageRemoved>( AddEventMessage<AppLanguageRemoved>(
"removed language {[Language]}"); "removed language {[Language]}.");
AddEventMessage<AppLanguageUpdated>( AddEventMessage<AppLanguageUpdated>(
"updated language {[Language]}"); "updated language {[Language]}.");
AddEventMessage<AppMasterLanguageSet>( AddEventMessage<AppMasterLanguageSet>(
"changed master language to {[Language]}"); "changed master language to {[Language]}.");
AddEventMessage<AppPatternAdded>(
"added pattern {[Pattern]}.");
AddEventMessage<AppPatternDeleted>(
"deleted pattern {[Pattern]}.");
AddEventMessage<AppPatternUpdated>(
"updated pattern {[Pattern]}.");
} }
protected Task<HistoryEventToStore> On(AppContributorRemoved @event) protected Task<HistoryEventToStore> On(AppContributorRemoved @event)
@ -131,6 +140,33 @@ namespace Squidex.Domain.Apps.Entities.Apps
.AddParameter("Language", @event.Language)); .AddParameter("Language", @event.Language));
} }
protected Task<HistoryEventToStore> On(AppPatternAdded @event)
{
const string channel = "settings.patterns";
return Task.FromResult(
ForEvent(@event, channel)
.AddParameter("Pattern", @event.Name));
}
protected Task<HistoryEventToStore> On(AppPatternDeleted @event)
{
const string channel = "settings.patterns";
return Task.FromResult(
ForEvent(@event, channel)
.AddParameter("Pattern", @event.Name));
}
protected Task<HistoryEventToStore> On(AppPatternUpdated @event)
{
const string channel = "settings.patterns";
return Task.FromResult(
ForEvent(@event, channel)
.AddParameter("Pattern", @event.Name));
}
protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event) protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event)
{ {
return this.DispatchFuncAsync(@event.Payload, (HistoryEventToStore)null); return this.DispatchFuncAsync(@event.Payload, (HistoryEventToStore)null);

13
src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
public sealed class DiscardChanges : ContentCommand
{
}
}

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

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

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

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

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

@ -42,6 +42,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
public NamedContentData PendingData { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result) public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();

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

@ -61,65 +61,51 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
GuardContent.CanCreate(c); GuardContent.CanCreate(c);
var operationContext = await CreateContext(c, () => "Failed to create content."); var operationContext = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content.");
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create", c, c.Data, null);
await operationContext.EnrichAsync(c.Data);
await operationContext.ValidateAsync(c.Data);
if (c.Publish) if (c.Publish)
{ {
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published"); await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published", c, c.Data, null);
} }
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create");
await operationContext.EnrichAsync();
await operationContext.ValidateAsync();
Create(c); Create(c);
return EntityCreatedResult.Create(c.Data, NewVersion); return EntityCreatedResult.Create(c.Data, NewVersion);
}); });
case UpdateContent updateContent: case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c => return UpdateReturnAsync(updateContent, c =>
{ {
GuardContent.CanUpdate(c); GuardContent.CanUpdate(c);
var operationContext = await CreateContext(c, () => "Failed to update content."); return UpdateContentAsync(c, c.Data, "Update", c.AsProposal);
await operationContext.ValidateAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update");
Update(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
}); });
case PatchContent patchContent: case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c => return UpdateReturnAsync(patchContent, c =>
{ {
GuardContent.CanPatch(c); GuardContent.CanPatch(c);
var operationContext = await CreateContext(c, () => "Failed to patch content."); return UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal);
await operationContext.ValidatePartialAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Patch");
Patch(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
}); });
case ChangeContentStatus patchContent: case ChangeContentStatus changeContentStatus:
return UpdateAsync(patchContent, async c => return UpdateReturnAsync(changeContentStatus, c =>
{ {
GuardContent.CanChangeContentStatus(Snapshot.Status, c); GuardContent.CanChangeContentStatus(Snapshot.PendingData, Snapshot.Status, c);
if (!c.DueTime.HasValue) if (Snapshot.PendingData != null)
{ {
var operationContext = await CreateContext(c, () => "Failed to patch content."); return UpdateContentAsync(c, Snapshot.PendingData, "Update", false);
}
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status); else
{
return ChangeStatusAsync(c);
} }
ChangeStatus(c);
}); });
case DeleteContent deleteContent: case DeleteContent deleteContent:
@ -127,18 +113,63 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
GuardContent.CanDelete(c); GuardContent.CanDelete(c);
var operationContext = await CreateContext(c, () => "Failed to delete content."); var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to delete content.");
await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete"); await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete", c, Snapshot.Data);
Delete(c); Delete(c);
}); });
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.PendingData, c);
DiscardChanges(c);
});
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }
} }
private async Task<object> ChangeStatusAsync(ChangeContentStatus c)
{
if (!c.DueTime.HasValue)
{
var operationContext = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content.");
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data);
}
ChangeStatus(c);
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.");
if (!Snapshot.Data.Equals(data))
{
await operationContext.ValidateAsync(data);
if (asProposal)
{
ProposeUpdate(command, data);
}
else
{
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", command, data, Snapshot.Data);
Update(command, data);
}
}
return new ContentDataChangedResult(data, NewVersion);
}
public void Create(CreateContent command) public void Create(CreateContent command)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentCreated())); RaiseEvent(SimpleMapper.Map(command, new ContentCreated()));
@ -149,12 +180,24 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public void Update(UpdateContent command) public void DiscardChanges(DiscardChanges command)
{ {
if (!command.Data.Equals(Snapshot.Data)) RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));
{ }
RaiseEvent(SimpleMapper.Map(command, new ContentUpdated()));
} public void Delete(DeleteContent command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
}
public void Update(ContentCommand command, NamedContentData data)
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdated { Data = data }));
}
public void ProposeUpdate(ContentCommand command, NamedContentData data)
{
RaiseEvent(SimpleMapper.Map(command, new ContentUpdateProposed { Data = data }));
} }
public void ChangeStatus(ChangeContentStatus command) public void ChangeStatus(ChangeContentStatus command)
@ -169,25 +212,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public void Patch(PatchContent command)
{
var newData = command.Data.MergeInto(Snapshot.Data);
if (!newData.Equals(Snapshot.Data))
{
var @event = SimpleMapper.Map(command, new ContentUpdated());
@event.Data = newData;
RaiseEvent(@event);
}
}
public void Delete(DeleteContent command)
{
RaiseEvent(SimpleMapper.Map(command, new ContentDeleted()));
}
private void RaiseEvent(SchemaEvent @event) private void RaiseEvent(SchemaEvent @event)
{ {
if (@event.AppId == null) if (@event.AppId == null)
@ -216,13 +240,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
ApplySnapshot(Snapshot.Apply(@event)); ApplySnapshot(Snapshot.Apply(@event));
} }
private async Task<ContentOperationContext> CreateContext(ContentCommand command, Func<string> message) private async Task<ContentOperationContext> CreateContext(Guid appId, Guid schemaId, Func<string> message)
{ {
var operationContext = var operationContext =
await ContentOperationContext.CreateAsync(command, Snapshot, await ContentOperationContext.CreateAsync(appId, schemaId,
contentRepository,
appProvider, appProvider,
assetRepository, assetRepository,
contentRepository,
scriptEngine, scriptEngine,
message); message);

22
src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs

@ -20,16 +20,25 @@ namespace Squidex.Domain.Apps.Entities.Contents
: base(typeNameRegistry) : base(typeNameRegistry)
{ {
AddEventMessage<ContentCreated>( AddEventMessage<ContentCreated>(
"created {[Schema]} content item."); "created {[Schema]} content.");
AddEventMessage<ContentUpdated>( AddEventMessage<ContentUpdated>(
"updated {[Schema]} content item."); "updated {[Schema]} content.");
AddEventMessage<ContentDeleted>( AddEventMessage<ContentDeleted>(
"deleted {[Schema]} content item."); "deleted {[Schema]} content.");
AddEventMessage<ContentChangesDiscarded>(
"discarded pending changes of {[Schema]} content.");
AddEventMessage<ContentUpdateProposed>(
"proposed update for {[Schema]} content.");
AddEventMessage<ContentStatusChanged>( AddEventMessage<ContentStatusChanged>(
"changed status of {[Schema]} content item to {[Status]}."); "changed status of {[Schema]} content to {[Status]}.");
AddEventMessage<ContentStatusScheduled>(
"scheduled to change status of {[Schema]} content to {[Status]}.");
} }
protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event) protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event)
@ -48,6 +57,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
result = result.AddParameter("Status", contentStatusChanged.Status); result = result.AddParameter("Status", contentStatusChanged.Status);
} }
if (@event.Payload is ContentStatusScheduled contentStatusScheduled)
{
result = result.AddParameter("Status", contentStatusScheduled.Status);
}
return Task.FromResult(result); return Task.FromResult(result);
} }
} }

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

@ -24,44 +24,29 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentOperationContext public sealed class ContentOperationContext
{ {
private ContentCommand command;
private IContentRepository contentRepository; private IContentRepository contentRepository;
private IContentEntity content;
private IAssetRepository assetRepository; private IAssetRepository assetRepository;
private IScriptEngine scriptEngine; private IScriptEngine scriptEngine;
private ISchemaEntity schemaEntity; private ISchemaEntity schemaEntity;
private IAppEntity appEntity; private IAppEntity appEntity;
private Guid appId;
private Func<string> message; private Func<string> message;
public static async Task<ContentOperationContext> CreateAsync( public static async Task<ContentOperationContext> CreateAsync(
ContentCommand command, Guid appId,
IContentEntity content, Guid schemaId,
IContentRepository contentRepository,
IAppProvider appProvider, IAppProvider appProvider,
IAssetRepository assetRepository, IAssetRepository assetRepository,
IContentRepository contentRepository,
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
Func<string> message) Func<string> message)
{ {
var a = content.AppId; var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(appId, schemaId);
var s = content.SchemaId;
if (command is CreateContent createContent)
{
a = a ?? createContent.AppId;
s = s ?? createContent.SchemaId;
}
var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(a.Id, s.Id);
var context = new ContentOperationContext var context = new ContentOperationContext
{ {
appEntity = appEntity, appEntity = appEntity,
appId = a.Id,
assetRepository = assetRepository, assetRepository = assetRepository,
contentRepository = contentRepository, contentRepository = contentRepository,
content = content,
command = command,
message = message, message = message,
schemaEntity = schemaEntity, schemaEntity = schemaEntity,
scriptEngine = scriptEngine scriptEngine = scriptEngine
@ -70,64 +55,41 @@ namespace Squidex.Domain.Apps.Entities.Contents
return context; return context;
} }
public Task EnrichAsync() public Task EnrichAsync(NamedContentData data)
{
if (command is ContentDataCommand dataCommand)
{
dataCommand.Data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
}
return TaskHelper.Done;
}
public Task ValidateAsync()
{ {
if (command is ContentDataCommand dataCommand) data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
{
var ctx = CreateValidationContext();
return dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
return TaskHelper.Done; return TaskHelper.Done;
} }
public Task ValidatePartialAsync() public Task ValidateAsync(NamedContentData data)
{ {
if (command is ContentDataCommand dataCommand) var ctx = CreateValidationContext();
{
var ctx = CreateValidationContext();
return dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
return TaskHelper.Done;
} }
public Task ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation) public Task<NamedContentData> ExecuteScriptAndTransformAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{ {
if (command is ContentDataCommand dataCommand) var ctx = CreateScriptContext(operation, command, data, oldData);
{
var ctx = CreateScriptContext(operation, dataCommand.Data);
dataCommand.Data = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity)); var result = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity));
}
return TaskHelper.Done; return Task.FromResult(result);
} }
public Task ExecuteScriptAsync(Func<ISchemaEntity, string> script, object operation) public Task ExecuteScriptAsync(Func<ISchemaEntity, string> script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null)
{ {
var ctx = CreateScriptContext(operation, content.Data); var ctx = CreateScriptContext(operation, command, data, oldData);
scriptEngine.Execute(ctx, script(schemaEntity)); scriptEngine.Execute(ctx, script(schemaEntity));
return TaskHelper.Done; return TaskHelper.Done;
} }
private ScriptContext CreateScriptContext(object operation, NamedContentData data = null) private ScriptContext CreateScriptContext(object operation, ContentCommand command, NamedContentData data, NamedContentData oldData)
{ {
return new ScriptContext { ContentId = command.ContentId, OldData = content.Data, Data = data, User = command.User, Operation = operation.ToString() }; return new ScriptContext { ContentId = command.ContentId, OldData = oldData, Data = data, User = command.User, Operation = operation.ToString() };
} }
private ValidationContext CreateValidationContext() private ValidationContext CreateValidationContext()
@ -145,12 +107,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
private async Task<IReadOnlyList<IAssetInfo>> QueryAssetsAsync(IEnumerable<Guid> assetIds) private async Task<IReadOnlyList<IAssetInfo>> QueryAssetsAsync(IEnumerable<Guid> assetIds)
{ {
return await assetRepository.QueryAsync(appId, new HashSet<Guid>(assetIds)); return await assetRepository.QueryAsync(appEntity.Id, new HashSet<Guid>(assetIds));
} }
private async Task<IReadOnlyList<Guid>> QueryContentsAsync(Guid schemaId, IEnumerable<Guid> contentIds) private async Task<IReadOnlyList<Guid>> QueryContentsAsync(Guid schemaId, IEnumerable<Guid> contentIds)
{ {
return await contentRepository.QueryNotFoundAsync(appId, schemaId, contentIds.ToList()); return await contentRepository.QueryNotFoundAsync(appEntity.Id, schemaId, contentIds.ToList());
} }
} }
} }

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

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

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

@ -32,5 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
RefToken ScheduledBy { get; } RefToken ScheduledBy { get; }
NamedContentData Data { get; } NamedContentData Data { get; }
NamedContentData PendingData { get; }
} }
} }

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

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Contents.State namespace Squidex.Domain.Apps.Entities.Contents.State
{ {
@ -29,6 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
[JsonProperty] [JsonProperty]
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
[JsonProperty]
public NamedContentData PendingData { get; set; }
[JsonProperty] [JsonProperty]
public Status Status { get; set; } public Status Status { get; set; }
@ -46,15 +50,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
protected void On(ContentCreated @event) protected void On(ContentCreated @event)
{ {
SchemaId = @event.SchemaId; SimpleMapper.Map(@event, this);
}
Data = @event.Data; protected void On(ContentUpdateProposed @event)
{
PendingData = @event.Data;
}
AppId = @event.AppId; protected void On(ContentChangesDiscarded @event)
{
PendingData = null;
} }
protected void On(ContentUpdated @event) protected void On(ContentUpdated @event)
{ {
PendingData = null;
Data = @event.Data; Data = @event.Data;
} }

26
src/Squidex.Domain.Apps.Entities/Contents/State/ContentStateScheduleItem.cs

@ -1,26 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.State
{
public sealed class ContentStateScheduleItem : IContentScheduleItem
{
[JsonProperty]
public Instant ScheduledAt { get; set; }
[JsonProperty]
public RefToken ScheduledBy { get; set; }
[JsonProperty]
public Status ScheduledTo { get; set; }
}
}

30
src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs

@ -20,49 +20,49 @@ namespace Squidex.Domain.Apps.Entities.Schemas
: base(typeNameRegistry) : base(typeNameRegistry)
{ {
AddEventMessage<SchemaCreated>( AddEventMessage<SchemaCreated>(
"created schema {[Name]}"); "created schema {[Name]}.");
AddEventMessage<SchemaUpdated>( AddEventMessage<SchemaUpdated>(
"updated schema {[Name]}"); "updated schema {[Name]}.");
AddEventMessage<SchemaDeleted>( AddEventMessage<SchemaDeleted>(
"deleted schema {[Name]}"); "deleted schema {[Name]}.");
AddEventMessage<SchemaPublished>( AddEventMessage<SchemaPublished>(
"published schema {[Name]}"); "published schema {[Name]}.");
AddEventMessage<SchemaUnpublished>( AddEventMessage<SchemaUnpublished>(
"unpublished schema {[Name]}"); "unpublished schema {[Name]}.");
AddEventMessage<SchemaFieldsReordered>( AddEventMessage<SchemaFieldsReordered>(
"reordered fields of schema {[Name]}"); "reordered fields of schema {[Name]}.");
AddEventMessage<FieldAdded>( AddEventMessage<FieldAdded>(
"added field {[Field]} to schema {[Name]}"); "added field {[Field]} to schema {[Name]}.");
AddEventMessage<FieldDeleted>( AddEventMessage<FieldDeleted>(
"deleted field {[Field]} from schema {[Name]}"); "deleted field {[Field]} from schema {[Name]}.");
AddEventMessage<FieldLocked>( AddEventMessage<FieldLocked>(
"has locked field {[Field]} of schema {[Name]}"); "has locked field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldHidden>( AddEventMessage<FieldHidden>(
"has hidden field {[Field]} of schema {[Name]}"); "has hidden field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldShown>( AddEventMessage<FieldShown>(
"has shown field {[Field]} of schema {[Name]}"); "has shown field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDisabled>( AddEventMessage<FieldDisabled>(
"disabled field {[Field]} of schema {[Name]}"); "disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldEnabled>( AddEventMessage<FieldEnabled>(
"disabled field {[Field]} of schema {[Name]}"); "disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldUpdated>( AddEventMessage<FieldUpdated>(
"has updated field {[Field]} of schema {[Name]}"); "has updated field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDeleted>( AddEventMessage<FieldDeleted>(
"deleted field {[Field]} of schema {[Name]}"); "deleted field {[Field]} of schema {[Name]}.");
} }
protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event) protected override Task<HistoryEventToStore> CreateEventCoreAsync(Envelope<IEvent> @event)

16
src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.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(ContentChangesDiscarded))]
public sealed class ContentChangesDiscarded : ContentEvent
{
}
}

18
src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs

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

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

@ -129,6 +129,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
itemModel.Data = item.Data.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); itemModel.Data = item.Data.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
} }
if (item.PendingData != null && isFrontendClient)
{
itemModel.PendingData = item.PendingData.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
return itemModel; return itemModel;
}).ToArray() }).ToArray()
}; };
@ -165,7 +170,15 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var isFrontendClient = User.IsFrontendClient(); var isFrontendClient = User.IsFrontendClient();
response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); if (entity.Data != null)
{
response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
if (entity.PendingData != null && isFrontendClient)
{
response.PendingData = entity.PendingData.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
} }
Response.Headers["ETag"] = entity.Version.ToString(); Response.Headers["ETag"] = entity.Version.ToString();
@ -195,15 +208,23 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version) public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version)
{ {
var content = await contentQuery.FindContentAsync(App, name, User, id, version); var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id, version);
var response = SimpleMapper.Map(content.Content, new ContentDto()); var response = SimpleMapper.Map(entity, new ContentDto());
if (content.Content.Data != null) if (entity.Data != null)
{ {
var isFrontendClient = User.IsFrontendClient(); var isFrontendClient = User.IsFrontendClient();
response.Data = content.Content.Data.ToApiModel(content.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); if (entity.Data != null)
{
response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
if (entity.PendingData != null && isFrontendClient)
{
response.PendingData = entity.PendingData.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
} }
Response.Headers["ETag"] = version.ToString(); Response.Headers["ETag"] = version.ToString();
@ -251,6 +272,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to update.</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="request">The full data for the content item.</param>
/// <param name="asProposal">Indicates whether the update is a proposal.</param>
/// <returns> /// <returns>
/// 200 => Content updated. /// 200 => Content updated.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
@ -263,11 +285,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request) public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
{ {
await contentQuery.FindSchemaAsync(App, name); await contentQuery.FindSchemaAsync(App, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
@ -284,6 +306,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to patch.</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="request">The patch for the content item.</param>
/// <param name="asProposal">Indicates whether the patch is a proposal.</param>
/// <returns> /// <returns>
/// 200 => Content patched. /// 200 => Content patched.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
@ -296,11 +319,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPatch] [HttpPatch]
[Route("content/{app}/{name}/{id}/")] [Route("content/{app}/{name}/{id}/")]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request) public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
{ {
await contentQuery.FindSchemaAsync(App, name); await contentQuery.FindSchemaAsync(App, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsProposal = asProposal };
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
@ -430,6 +453,35 @@ namespace Squidex.Areas.Api.Controllers.Contents
return NoContent(); return NoContent();
} }
/// <summary>
/// Discard changes of a content item.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to discard changes.</param>
/// <returns>
/// 204 => Content restored.
/// 404 => Content, schema or app not found.
/// 400 => Content was not archived.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs
/// </remarks>
[MustBeAppEditor]
[HttpPut]
[Route("content/{app}/{name}/{id}/discard/")]
[ApiCosts(1)]
public async Task<IActionResult> DiscardChanges(string app, string name, Guid id)
{
await contentQuery.FindSchemaAsync(App, name);
var command = new DiscardChanges { ContentId = id };
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary> /// <summary>
/// Delete a content item. /// Delete a content item.
/// </summary> /// </summary>

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

@ -40,6 +40,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[Required] [Required]
public object Data { get; set; } public object Data { get; set; }
/// <summary>
/// The pending changes of the content item.
/// </summary>
public object PendingData { get; set; }
/// <summary> /// <summary>
/// The scheduled status. /// The scheduled status.
/// </summary> /// </summary>

6
src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs

@ -25,6 +25,12 @@ namespace Squidex.Areas.Api.Controllers.History.Models
[Required] [Required]
public string Actor { get; set; } public string Actor { get; set; }
/// <summary>
/// The type of the event.
/// </summary>
[Required]
public string EventType { get; set; }
/// <summary> /// <summary>
/// Gets a unique id for the event. /// Gets a unique id for the event.
/// </summary> /// </summary>

2
src/Squidex/app/features/content/declarations.ts

@ -11,6 +11,8 @@ export * from './pages/content/content-page.component';
export * from './pages/contents/contents-page.component'; export * from './pages/contents/contents-page.component';
export * from './pages/contents/search-form.component'; export * from './pages/contents/search-form.component';
export * from './pages/schemas/schemas-page.component'; export * from './pages/schemas/schemas-page.component';
export * from './shared/assets-editor.component'; export * from './shared/assets-editor.component';
export * from './shared/content-item.component'; export * from './shared/content-item.component';
export * from './shared/content-status.component';
export * from './shared/references-editor.component'; export * from './shared/references-editor.component';

2
src/Squidex/app/features/content/module.ts

@ -24,6 +24,7 @@ import {
ContentHistoryComponent, ContentHistoryComponent,
ContentPageComponent, ContentPageComponent,
ContentItemComponent, ContentItemComponent,
ContentStatusComponent,
ContentsPageComponent, ContentsPageComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
SchemasPageComponent, SchemasPageComponent,
@ -115,6 +116,7 @@ const routes: Routes = [
ContentHistoryComponent, ContentHistoryComponent,
ContentItemComponent, ContentItemComponent,
ContentPageComponent, ContentPageComponent,
ContentStatusComponent,
ContentsPageComponent, ContentsPageComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
SchemasPageComponent, SchemasPageComponent,

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

@ -5,22 +5,48 @@
<div class="panel-header"> <div class="panel-header">
<div class="panel-title-row"> <div class="panel-title-row">
<div class="float-right" *ngIf="!content || content.status !== 'Archived'"> <div class="float-right" *ngIf="!content || content.status !== 'Archived'">
<div class="btn btn-outline-secondary content-status" *ngIf="content">
<sqx-content-status
[status]="content.status"
[scheduledTo]="content.scheduledTo"
[scheduledAt]="content.scheduledAt"
[hasPendingChanges]="content.pendingData"
[showLabel]="true">
</sqx-content-status>
<a *ngIf="content.pendingData" (click)="discardChanges()" class="discard-changes">
Discard changes
</a>
</div>
<span *ngIf="isNewMode"> <span *ngIf="isNewMode">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S"> <button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save as Draft Save
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-success">
Save and Publish Save and Publish
</button> </button>
</span> </span>
<span *ngIf="!isNewMode"> <span *ngIf="!isNewMode && !content.pendingData">
<button type="submit" class="btn btn-primary" title="CTRL + S"> <button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save Save
</button> </button>
<button type="submit" class="btn btn-success">
Save and Publish
</button>
</span>
<span *ngIf="!isNewMode && content.pendingData">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save
</button>
<button type="button" class="btn btn-success" (click)="confirmChanges()">
Publish Changes
</button>
</span> </span>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut> <sqx-shortcut keys="ctrl+s" (trigger)="saveAsDraft()"></sqx-shortcut>
</div> </div>
<h3 class="panel-title" *ngIf="isNewMode"> <h3 class="panel-title" *ngIf="isNewMode">

18
src/Squidex/app/features/content/pages/content/content-page.component.scss

@ -1,2 +1,18 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
.content-status {
& {
color: $color-text;
}
&:hover {
background: transparent;
}
}
.discard-changes {
color: $color-theme-blue !important;
font-size: .9rem;
font-weight: normal;
}

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

@ -165,15 +165,26 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
this.enableContentForm(); this.enableContentForm();
}); });
} else { } else {
this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, this.content.version) this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, !publish, this.content.version)
.subscribe(dto => { .subscribe(dto => {
const content = this.content.update(dto.payload, this.ctx.userToken, dto.version); let content = this.content;
this.ctx.notifyInfo('Content saved successfully.'); if (dto.version.value !== content.version.value) {
if (publish) {
content = this.content.update(dto.payload, this.ctx.userToken, dto.version);
} else {
content = this.content.proposeUpdate(dto.payload, this.ctx.userToken, dto.version);
}
this.ctx.notifyInfo('Content saved successfully.');
this.emitContentUpdated(content);
this.reloadContentForm(content);
} else {
this.ctx.notifyInfo('Content has not changed.');
}
this.emitContentUpdated(content);
this.enableContentForm(); this.enableContentForm();
this.reloadContentForm(content);
}, error => { }, error => {
this.ctx.notifyError(error); this.ctx.notifyError(error);
@ -185,6 +196,34 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
} }
} }
public confirmChanges() {
this.contentsService.changeContentStatus(this.ctx.appName, this.schema.name, this.content.id, 'publish', null, this.content.version)
.subscribe(dto => {
const content = this.content.confirmChanges(this.ctx.userToken, dto.version);
this.ctx.notifyInfo('Content changes have been updated.');
this.emitContentUpdated(content);
this.reloadContentForm(content);
}, error => {
this.ctx.notifyError(error);
});
}
public discardChanges() {
this.contentsService.discardChanges(this.ctx.appName, this.schema.name, this.content.id, this.content.version)
.subscribe(dto => {
const content = this.content.discardChanges(this.ctx.userToken, dto.version);
this.ctx.notifyInfo('Content changes have been discarded.');
this.emitContentUpdated(content);
this.reloadContentForm(content);
}, error => {
this.ctx.notifyError(error);
});
}
private loadVersion(version: number) { private loadVersion(version: number) {
if (!this.isNewMode && this.content) { if (!this.isNewMode && this.content) {
this.contentsService.getVersionData(this.ctx.appName, this.schema.name, this.content.id, new Version(version.toString())) this.contentsService.getVersionData(this.ctx.appName, this.schema.name, this.content.id, new Version(version.toString()))
@ -274,7 +313,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (!this.isNewMode) { if (!this.isNewMode) {
for (const field of this.schema.fields) { for (const field of this.schema.fields) {
const fieldValue = this.content.data[field.name] || {}; const fieldValue = this.content.displayData[field.name] || {};
const fieldForm = <FormGroup>this.contentForm.controls[field.name]; const fieldForm = <FormGroup>this.contentForm.controls[field.name];
if (field.isLocalizable) { if (field.isLocalizable) {

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

@ -94,7 +94,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.contentUpdatedSubscription = this.contentUpdatedSubscription =
this.ctx.bus.of(ContentUpdated) this.ctx.bus.of(ContentUpdated)
.subscribe(message => { .subscribe(message => {
this.contentItems = this.contentItems.replaceBy('id', message.content, (o, n) => o.update(n.data, n.lastModifiedBy, n.version, n.lastModified)); this.contentItems = this.contentItems.replaceBy('id', message.content);
}); });
const routeData = allData(this.ctx.route); const routeData = allData(this.ctx.route);

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

@ -56,21 +56,12 @@
</div> </div>
</td> </td>
<td class="cell-time" (click)="shouldStop($event)"> <td class="cell-time" (click)="shouldStop($event)">
<span *ngIf="!content.scheduledTo"> <sqx-content-status
<span class="content-status content-status-{{content.status | lowercase}}" #statusIcon> [status]="content.status"
<i class="icon-circle"></i> [scheduledTo]="content.scheduledTo"
</span> [scheduledAt]="content.scheduledAt"
[hasPendingChanges]="content.pendingData">
<sqx-tooltip [target]="statusIcon">{{content.status}}</sqx-tooltip> </sqx-content-status>
</span>
<span *ngIf="content.scheduledTo">
<span class="content-status content-status-{{content.scheduledTo | lowercase}}" #statusIcon>
<i class="icon-clock"></i>
</span>
<sqx-tooltip [target]="statusIcon">Will be set to '{{content.scheduledTo}}' at {{content.scheduledAt | sqxFullDateTime}}</sqx-tooltip>
</span>
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small> <small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
</td> </td>

28
src/Squidex/app/features/content/shared/content-item.component.scss

@ -3,32 +3,4 @@
.truncate { .truncate {
@include truncate; @include truncate;
}
.content-status {
& {
vertical-align: middle;
}
&-published {
color: $color-theme-green;
}
&-draft {
color: $color-text-decent;
}
&-archived {
color: $color-theme-error;
}
&-tooltip {
@include border-radius;
background: $color-tooltip;
border: 0;
font-size: .9rem;
font-weight: normal;
color: $color-dark-foreground;
padding: .75rem;
}
} }

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

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

17
src/Squidex/app/features/content/shared/content-status.component.html

@ -0,0 +1,17 @@
<span *ngIf="!scheduledTo">
<span class="content-status content-status-{{displayStatus | lowercase}}" #statusIcon>
<i class="icon-circle"></i>
</span>
<sqx-tooltip [target]="statusIcon">{{displayStatus}}</sqx-tooltip>
</span>
<span *ngIf="scheduledTo">
<span class="content-status content-status-{{scheduledTo | lowercase}}" #statusIcon>
<i class="icon-clock"></i>
</span>
<sqx-tooltip [target]="statusIcon">Will be set to '{{scheduledTo}}' at {{scheduledAt | sqxFullDateTime}}</sqx-tooltip>
</span>
<span *ngIf="showLabel">{{displayStatus}}</span>

34
src/Squidex/app/features/content/shared/content-status.component.scss

@ -0,0 +1,34 @@
@import '_vars';
@import '_mixins';
.content-status {
& {
vertical-align: middle;
}
&-published {
color: $color-theme-green;
}
&-draft {
color: $color-text-decent;
}
&-archived {
color: $color-theme-error;
}
&-pending {
color: $color-dark-black;
}
&-tooltip {
@include border-radius;
background: $color-tooltip;
border: 0;
font-size: .9rem;
font-weight: normal;
color: $color-dark-foreground;
padding: .75rem;
}
}

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

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { DateTime } from 'shared';
@Component({
selector: 'sqx-content-status',
styleUrls: ['./content-status.component.scss'],
templateUrl: './content-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentStatusComponent {
@Input()
public status: string;
@Input()
public scheduledTo?: string;
@Input()
public scheduledAt?: DateTime;
@Input()
public hasPendingChanges: any;
@Input()
public showLabel = false;
public get displayStatus() {
return !!this.hasPendingChanges ? 'Pending' : this.status;
}
}

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

@ -28,7 +28,7 @@ describe('ContentDto', () => {
const newVersion = new Version('2'); const newVersion = new Version('2');
it('should update data property and user info when updating', () => { it('should update data property and user info when updating', () => {
const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version); const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version);
const content_2 = content_1.update({ data: 2 }, modifier, newVersion, modified); const content_2 = content_1.update({ data: 2 }, modifier, newVersion, modified);
expect(content_2.data).toEqual({ data: 2 }); expect(content_2.data).toEqual({ data: 2 });
@ -38,7 +38,7 @@ describe('ContentDto', () => {
}); });
it('should update status property and user info when changing status', () => { it('should update status property and user info when changing status', () => {
const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version); const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version);
const content_2 = content_1.changeStatus('Published', null, modifier, newVersion, modified); const content_2 = content_1.changeStatus('Published', null, modifier, newVersion, modified);
expect(content_2.status).toEqual('Published'); expect(content_2.status).toEqual('Published');
@ -48,7 +48,7 @@ describe('ContentDto', () => {
}); });
it('should update schedules property and user info when changing status with due time', () => { it('should update schedules property and user info when changing status with due time', () => {
const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version); const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version);
const content_2 = content_1.changeStatus('Published', dueTime, modifier, newVersion, modified); const content_2 = content_1.changeStatus('Published', dueTime, modifier, newVersion, modified);
expect(content_2.status).toEqual('Draft'); expect(content_2.status).toEqual('Draft');
@ -63,7 +63,7 @@ describe('ContentDto', () => {
it('should update data property when setting data', () => { it('should update data property when setting data', () => {
const newData = {}; const newData = {};
const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version); const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, {}, version);
const content_2 = content_1.setData(newData); const content_2 = content_1.setData(newData);
expect(content_2.data).toBe(newData); expect(content_2.data).toBe(newData);
@ -142,6 +142,7 @@ describe('ContentsService', () => {
'Scheduler1', 'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'), DateTime.parseISO_UTC('2018-12-12T10:10'),
{}, {},
undefined,
new Version('11')), new Version('11')),
new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2', new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'), DateTime.parseISO_UTC('2016-10-12T10:10'),
@ -150,6 +151,7 @@ describe('ContentsService', () => {
null, null,
null, null,
{}, {},
undefined,
new Version('22')) new Version('22'))
])); ]));
})); }));
@ -232,6 +234,7 @@ describe('ContentsService', () => {
'Scheduler1', 'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'), DateTime.parseISO_UTC('2018-12-12T10:10'),
{}, {},
undefined,
new Version('2'))); new Version('2')));
})); }));
@ -273,6 +276,7 @@ describe('ContentsService', () => {
null, null,
null, null,
{}, {},
undefined,
new Version('2'))); new Version('2')));
})); }));
@ -302,9 +306,9 @@ describe('ContentsService', () => {
const dto = {}; const dto = {};
contentsService.putContent('my-app', 'my-schema', 'content1', dto, version).subscribe(); 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'); const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?asProposal=true');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);

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

@ -29,6 +29,8 @@ export class ContentsDto {
} }
export class ContentDto { export class ContentDto {
public displayData: any;
constructor( constructor(
public readonly id: string, public readonly id: string,
public readonly status: string, public readonly status: string,
@ -40,8 +42,10 @@ export class ContentDto {
public readonly scheduledBy: string | null, public readonly scheduledBy: string | null,
public readonly scheduledAt: DateTime | null, public readonly scheduledAt: DateTime | null,
public readonly data: any, public readonly data: any,
public readonly pendingData: any,
public readonly version: Version public readonly version: Version
) { ) {
this.displayData = pendingData || data;
} }
public setData(data: any): ContentDto { public setData(data: any): ContentDto {
@ -56,6 +60,7 @@ export class ContentDto {
this.scheduledBy, this.scheduledBy,
this.scheduledAt, this.scheduledAt,
data, data,
this.pendingData,
this.version); this.version);
} }
@ -70,6 +75,7 @@ export class ContentDto {
user, user,
dueTime, dueTime,
this.data, this.data,
this.pendingData,
version); version);
} else { } else {
return new ContentDto( return new ContentDto(
@ -81,6 +87,7 @@ export class ContentDto {
null, null,
null, null,
this.data, this.data,
this.pendingData,
version); version);
} }
} }
@ -95,6 +102,49 @@ export class ContentDto {
this.scheduledBy, this.scheduledBy,
this.scheduledAt, this.scheduledAt,
data, data,
this.pendingData,
version);
}
public proposeUpdate(data: any, user: string, version: Version, now?: DateTime): ContentDto {
return new ContentDto(
this.id,
this.status,
this.createdBy, user,
this.created, now || DateTime.now(),
this.scheduledTo,
this.scheduledBy,
this.scheduledAt,
this.data,
data,
version);
}
public confirmChanges(user: string, version: Version, now?: DateTime): ContentDto {
return new ContentDto(
this.id,
this.status,
this.createdBy, user,
this.created, now || DateTime.now(),
this.scheduledTo,
this.scheduledBy,
this.scheduledAt,
this.pendingData,
null,
version);
}
public discardChanges(user: string, version: Version, now?: DateTime): ContentDto {
return new ContentDto(
this.id,
this.status,
this.createdBy, user,
this.created, now || DateTime.now(),
this.scheduledTo,
this.scheduledBy,
this.scheduledAt,
this.data,
null,
version); version);
} }
} }
@ -159,6 +209,7 @@ export class ContentsService {
item.scheduledBy || null, item.scheduledBy || null,
item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null, item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null,
item.data, item.data,
item.pendingData,
new Version(item.version.toString())); new Version(item.version.toString()));
})); }));
}) })
@ -183,6 +234,7 @@ export class ContentsService {
body.scheduledBy || null, body.scheduledBy || null,
body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null, body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null,
body.data, body.data,
body.pendingData,
response.version); response.version);
}) })
.pretifyError('Failed to load content. Please reload.'); .pretifyError('Failed to load content. Please reload.');
@ -216,6 +268,7 @@ export class ContentsService {
null, null,
null, null,
body.data, body.data,
body.pendingData,
response.version); response.version);
}) })
.do(content => { .do(content => {
@ -224,8 +277,8 @@ export class ContentsService {
.pretifyError('Failed to create content. Please reload.'); .pretifyError('Failed to create content. Please reload.');
} }
public putContent(appName: string, schemaName: string, id: string, dto: any, version: Version): Observable<Versioned<any>> { 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}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asProposal=${asProposal}`);
return HTTP.putVersioned(this.http, url, dto, version) return HTTP.putVersioned(this.http, url, dto, version)
.map(response => { .map(response => {
@ -254,6 +307,16 @@ export class ContentsService {
.pretifyError('Failed to update content. Please reload.'); .pretifyError('Failed to update content. Please reload.');
} }
public discardChanges(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/discard`);
return HTTP.putVersioned(this.http, url, version)
.do(() => {
this.analytics.trackEvent('Content', 'Discarded', appName);
})
.pretifyError('Failed to discard changes. Please reload.');
}
public deleteContent(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> { public deleteContent(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);

2
tests/Squidex.Domain.Apps.Entities.Tests/Assets/Guards/GuardAssetTests.cs

@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards
} }
[Fact] [Fact]
public void CanRename_not_should_throw_exception_if_name_are_different() public void CanRename_should_not_throw_exception_if_name_are_different()
{ {
var command = new RenameAsset { FileName = "new-name" }; var command = new RenameAsset { FileName = "new-name" };

26
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 }; var command = new ChangeContentStatus { Status = (Status)10 };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(Status.Archived, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command));
} }
[Fact] [Fact]
@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(Status.Archived, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command));
} }
[Fact] [Fact]
@ -87,15 +87,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{ {
var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast }; var command = new ChangeContentStatus { Status = Status.Published, DueTime = dueTimeInPast };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(Status.Draft, command)); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Draft, command));
} }
[Fact] [Fact]
public void CanChangeContentStatus_not_should_throw_exception_if_status_flow_valid() public void CanChangeContentStatus_should_throw_exception_republishing_without_pending_changes()
{ {
var command = new ChangeContentStatus { Status = Status.Published }; var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(Status.Draft, command); Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Published, command));
}
[Fact]
public void CanChangeContentStatus_should_not_throw_exception_republishing_with_pending_changes()
{
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(new NamedContentData(), Status.Published, command);
}
[Fact]
public void CanChangeContentStatus_should_not_throw_exception_if_status_flow_valid()
{
var command = new ChangeContentStatus { Status = Status.Published };
GuardContent.CanChangeContentStatus(null, Status.Draft, command);
} }
[Fact] [Fact]

Loading…
Cancel
Save