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
{
private NamedContentData data;
private NamedContentData pendingData;
[BsonId]
[BsonRequired]
@ -62,6 +63,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonJson]
public IdContentData DataByIds { get; set; }
[BsonIgnoreIfNull]
[BsonElement("dop")]
[BsonJson]
public IdContentData PendingDataByIds { get; set; }
[BsonRequired]
[BsonElement("ai2")]
public NamedId<Guid> AppId { get; set; }
@ -116,9 +122,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
get { return data; }
}
[BsonIgnore]
public NamedContentData PendingData
{
get { return pendingData; }
}
public void ParseData(Schema schema)
{
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(),
DataText = idData?.ToFullText(),
DataByIds = idData,
PendingDataByIds = value.PendingData?.ToIdModel(schema.SchemaDef, true),
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
{
get { return inner.Id; }
set { }
}
public Instant Created
{
get { return inner.Created; }
set { }
}
public Instant LastModified
{
get { return inner.LastModified; }
set { }
}
public RefToken Actor
@ -58,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
get { return inner.Channel; }
}
public string EventType
{
get { return inner.Message; }
}
public string Message
{
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)
{
AddEventMessage<AppContributorAssigned>(
"assigned {user:[Contributor]} as [Permission]");
"assigned {user:[Contributor]} as [Permission].");
AddEventMessage<AppContributorRemoved>(
"removed {user:[Contributor]} from app");
"removed {user:[Contributor]}.");
AddEventMessage<AppClientAttached>(
"added client {[Id]} to app");
"added client {[Id]}.");
AddEventMessage<AppClientRevoked>(
"revoked client {[Id]}");
"revoked client {[Id]}.");
AddEventMessage<AppClientUpdated>(
"updated client {[Id]}");
"updated client {[Id]}.");
AddEventMessage<AppClientRenamed>(
"renamed client {[Id]} to {[Name]}");
"renamed client {[Id]} to {[Name]}.");
AddEventMessage<AppLanguageAdded>(
"added language {[Language]}");
"added language {[Language]}.");
AddEventMessage<AppLanguageRemoved>(
"removed language {[Language]}");
"removed language {[Language]}.");
AddEventMessage<AppLanguageUpdated>(
"updated language {[Language]}");
"updated language {[Language]}.");
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)
@ -131,6 +140,33 @@ namespace Squidex.Domain.Apps.Entities.Apps
.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)
{
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 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 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 PendingData { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{
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);
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)
{
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);
return EntityCreatedResult.Create(c.Data, NewVersion);
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c =>
return UpdateReturnAsync(updateContent, c =>
{
GuardContent.CanUpdate(c);
var operationContext = await CreateContext(c, () => "Failed to update content.");
await operationContext.ValidateAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update");
Update(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
return UpdateContentAsync(c, c.Data, "Update", c.AsProposal);
});
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c =>
return UpdateReturnAsync(patchContent, c =>
{
GuardContent.CanPatch(c);
var operationContext = await CreateContext(c, () => "Failed to patch content.");
await operationContext.ValidatePartialAsync();
await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Patch");
Patch(c);
return new ContentDataChangedResult(Snapshot.Data, NewVersion);
return UpdateContentAsync(c, c.Data.MergeInto(Snapshot.PendingData ?? Snapshot.Data), "Patch", c.AsProposal);
});
case ChangeContentStatus patchContent:
return UpdateAsync(patchContent, async c =>
case ChangeContentStatus changeContentStatus:
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.");
await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status);
return UpdateContentAsync(c, Snapshot.PendingData, "Update", false);
}
else
{
return ChangeStatusAsync(c);
}
ChangeStatus(c);
});
case DeleteContent deleteContent:
@ -127,18 +113,63 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
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);
});
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.PendingData, c);
DiscardChanges(c);
});
default:
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)
{
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 ContentUpdated()));
}
RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded()));
}
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)
@ -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)
{
if (@event.AppId == null)
@ -216,13 +240,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
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 =
await ContentOperationContext.CreateAsync(command, Snapshot,
contentRepository,
await ContentOperationContext.CreateAsync(appId, schemaId,
appProvider,
assetRepository,
contentRepository,
scriptEngine,
message);

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

@ -20,16 +20,25 @@ namespace Squidex.Domain.Apps.Entities.Contents
: base(typeNameRegistry)
{
AddEventMessage<ContentCreated>(
"created {[Schema]} content item.");
"created {[Schema]} content.");
AddEventMessage<ContentUpdated>(
"updated {[Schema]} content item.");
"updated {[Schema]} content.");
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>(
"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)
@ -48,6 +57,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
result = result.AddParameter("Status", contentStatusChanged.Status);
}
if (@event.Payload is ContentStatusScheduled contentStatusScheduled)
{
result = result.AddParameter("Status", contentStatusScheduled.Status);
}
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
{
private ContentCommand command;
private IContentRepository contentRepository;
private IContentEntity content;
private IAssetRepository assetRepository;
private IScriptEngine scriptEngine;
private ISchemaEntity schemaEntity;
private IAppEntity appEntity;
private Guid appId;
private Func<string> message;
public static async Task<ContentOperationContext> CreateAsync(
ContentCommand command,
IContentEntity content,
IContentRepository contentRepository,
Guid appId,
Guid schemaId,
IAppProvider appProvider,
IAssetRepository assetRepository,
IContentRepository contentRepository,
IScriptEngine scriptEngine,
Func<string> message)
{
var a = content.AppId;
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 (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(appId, schemaId);
var context = new ContentOperationContext
{
appEntity = appEntity,
appId = a.Id,
assetRepository = assetRepository,
contentRepository = contentRepository,
content = content,
command = command,
message = message,
schemaEntity = schemaEntity,
scriptEngine = scriptEngine
@ -70,64 +55,41 @@ namespace Squidex.Domain.Apps.Entities.Contents
return context;
}
public Task EnrichAsync()
{
if (command is ContentDataCommand dataCommand)
{
dataCommand.Data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
}
return TaskHelper.Done;
}
public Task ValidateAsync()
public Task EnrichAsync(NamedContentData data)
{
if (command is ContentDataCommand dataCommand)
{
var ctx = CreateValidationContext();
return dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver());
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 TaskHelper.Done;
return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message);
}
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, dataCommand.Data);
var ctx = CreateScriptContext(operation, command, data, oldData);
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));
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()
@ -145,12 +107,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
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));
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)));
}

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

@ -32,5 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
RefToken ScheduledBy { 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.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Contents.State
{
@ -29,6 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
[JsonProperty]
public NamedContentData Data { get; set; }
[JsonProperty]
public NamedContentData PendingData { get; set; }
[JsonProperty]
public Status Status { get; set; }
@ -46,15 +50,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
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)
{
PendingData = null;
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)
{
AddEventMessage<SchemaCreated>(
"created schema {[Name]}");
"created schema {[Name]}.");
AddEventMessage<SchemaUpdated>(
"updated schema {[Name]}");
"updated schema {[Name]}.");
AddEventMessage<SchemaDeleted>(
"deleted schema {[Name]}");
"deleted schema {[Name]}.");
AddEventMessage<SchemaPublished>(
"published schema {[Name]}");
"published schema {[Name]}.");
AddEventMessage<SchemaUnpublished>(
"unpublished schema {[Name]}");
"unpublished schema {[Name]}.");
AddEventMessage<SchemaFieldsReordered>(
"reordered fields of schema {[Name]}");
"reordered fields of schema {[Name]}.");
AddEventMessage<FieldAdded>(
"added field {[Field]} to schema {[Name]}");
"added field {[Field]} to schema {[Name]}.");
AddEventMessage<FieldDeleted>(
"deleted field {[Field]} from schema {[Name]}");
"deleted field {[Field]} from schema {[Name]}.");
AddEventMessage<FieldLocked>(
"has locked field {[Field]} of schema {[Name]}");
"has locked field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldHidden>(
"has hidden field {[Field]} of schema {[Name]}");
"has hidden field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldShown>(
"has shown field {[Field]} of schema {[Name]}");
"has shown field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDisabled>(
"disabled field {[Field]} of schema {[Name]}");
"disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldEnabled>(
"disabled field {[Field]} of schema {[Name]}");
"disabled field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldUpdated>(
"has updated field {[Field]} of schema {[Name]}");
"has updated field {[Field]} of schema {[Name]}.");
AddEventMessage<FieldDeleted>(
"deleted field {[Field]} of schema {[Name]}");
"deleted field {[Field]} of schema {[Name]}.");
}
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);
}
if (item.PendingData != null && isFrontendClient)
{
itemModel.PendingData = item.PendingData.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient);
}
return itemModel;
}).ToArray()
};
@ -165,7 +170,15 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
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();
@ -195,15 +208,23 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
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();
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();
@ -251,6 +272,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>
/// <returns>
/// 200 => Content updated.
/// 404 => Content, schema or app not found.
@ -263,11 +285,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)
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
{
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);
@ -284,6 +306,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>
/// <returns>
/// 200 => Content patched.
/// 404 => Content, schema or app not found.
@ -296,11 +319,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)
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asProposal = false)
{
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);
@ -430,6 +453,35 @@ namespace Squidex.Areas.Api.Controllers.Contents
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>
/// Delete a content item.
/// </summary>

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

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

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

@ -25,6 +25,12 @@ namespace Squidex.Areas.Api.Controllers.History.Models
[Required]
public string Actor { get; set; }
/// <summary>
/// The type of the event.
/// </summary>
[Required]
public string EventType { get; set; }
/// <summary>
/// Gets a unique id for the event.
/// </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/search-form.component';
export * from './pages/schemas/schemas-page.component';
export * from './shared/assets-editor.component';
export * from './shared/content-item.component';
export * from './shared/content-status.component';
export * from './shared/references-editor.component';

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

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

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

@ -5,22 +5,48 @@
<div class="panel-header">
<div class="panel-title-row">
<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">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save as Draft
Save
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-success">
Save and Publish
</button>
</span>
<span *ngIf="!isNewMode">
<button type="submit" class="btn btn-primary" title="CTRL + S">
<span *ngIf="!isNewMode && !content.pendingData">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save
</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>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAndPublish()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+s" (trigger)="saveAsDraft()"></sqx-shortcut>
</div>
<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 '_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();
});
} 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 => {
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.reloadContentForm(content);
}, 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) {
if (!this.isNewMode && this.content) {
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) {
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];
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.ctx.bus.of(ContentUpdated)
.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);

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

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

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

@ -3,32 +3,4 @@
.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 {
const contentField = this.content.data[field.name];
const contentField = this.content.displayData[field.name];
if (contentField) {
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');
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);
expect(content_2.data).toEqual({ data: 2 });
@ -38,7 +38,7 @@ describe('ContentDto', () => {
});
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);
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', () => {
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);
expect(content_2.status).toEqual('Draft');
@ -63,7 +63,7 @@ describe('ContentDto', () => {
it('should update data property when setting data', () => {
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);
expect(content_2.data).toBe(newData);
@ -142,6 +142,7 @@ describe('ContentsService', () => {
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
{},
undefined,
new Version('11')),
new ContentDto('id2', 'Published', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'),
@ -150,6 +151,7 @@ describe('ContentsService', () => {
null,
null,
{},
undefined,
new Version('22'))
]));
}));
@ -232,6 +234,7 @@ describe('ContentsService', () => {
'Scheduler1',
DateTime.parseISO_UTC('2018-12-12T10:10'),
{},
undefined,
new Version('2')));
}));
@ -273,6 +276,7 @@ describe('ContentsService', () => {
null,
null,
{},
undefined,
new Version('2')));
}));
@ -302,9 +306,9 @@ describe('ContentsService', () => {
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.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 {
public displayData: any;
constructor(
public readonly id: string,
public readonly status: string,
@ -40,8 +42,10 @@ export class ContentDto {
public readonly scheduledBy: string | null,
public readonly scheduledAt: DateTime | null,
public readonly data: any,
public readonly pendingData: any,
public readonly version: Version
) {
this.displayData = pendingData || data;
}
public setData(data: any): ContentDto {
@ -56,6 +60,7 @@ export class ContentDto {
this.scheduledBy,
this.scheduledAt,
data,
this.pendingData,
this.version);
}
@ -70,6 +75,7 @@ export class ContentDto {
user,
dueTime,
this.data,
this.pendingData,
version);
} else {
return new ContentDto(
@ -81,6 +87,7 @@ export class ContentDto {
null,
null,
this.data,
this.pendingData,
version);
}
}
@ -95,6 +102,49 @@ export class ContentDto {
this.scheduledBy,
this.scheduledAt,
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);
}
}
@ -159,6 +209,7 @@ export class ContentsService {
item.scheduledBy || null,
item.scheduledAt ? DateTime.parseISO_UTC(item.scheduledAt) : null,
item.data,
item.pendingData,
new Version(item.version.toString()));
}));
})
@ -183,6 +234,7 @@ export class ContentsService {
body.scheduledBy || null,
body.scheduledAt || null ? DateTime.parseISO_UTC(body.scheduledAt) : null,
body.data,
body.pendingData,
response.version);
})
.pretifyError('Failed to load content. Please reload.');
@ -216,6 +268,7 @@ export class ContentsService {
null,
null,
body.data,
body.pendingData,
response.version);
})
.do(content => {
@ -224,8 +277,8 @@ export class ContentsService {
.pretifyError('Failed to create content. Please reload.');
}
public putContent(appName: string, schemaName: string, id: string, dto: any, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
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}`);
return HTTP.putVersioned(this.http, url, dto, version)
.map(response => {
@ -254,6 +307,16 @@ export class ContentsService {
.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>> {
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]
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" };

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 };
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(Status.Archived, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, 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(Status.Archived, command));
Assert.Throws<ValidationException>(() => GuardContent.CanChangeContentStatus(null, Status.Archived, command));
}
[Fact]
@ -87,15 +87,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guard
{
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]
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 };
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]

Loading…
Cancel
Save