Browse Source

Progress with contents.

pull/363/head
Sebastian Stehle 7 years ago
parent
commit
33b59e45b2
  1. 5
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
  2. 17
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  3. 5
      src/Squidex.Shared/Permissions.cs
  4. 164
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  5. 6
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs
  6. 23
      src/Squidex/Areas/Api/Controllers/Contents/Helper.cs
  7. 34
      src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs
  8. 77
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  9. 33
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  10. 57
      src/Squidex/app/features/content/pages/content/content-page.component.html
  11. 27
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  12. 27
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  13. 59
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  14. 27
      src/Squidex/app/features/content/shared/content-item.component.ts
  15. 234
      src/Squidex/app/shared/services/contents.service.spec.ts
  16. 176
      src/Squidex/app/shared/services/contents.service.ts
  17. 127
      src/Squidex/app/shared/state/contents.state.ts

5
src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs

@ -28,5 +28,10 @@ namespace Squidex.Domain.Apps.Core.Contents
{ {
return Flow.TryGetValue(status, out var state) && state.Contains(toStatus); return Flow.TryGetValue(status, out var state) && state.Contains(toStatus);
} }
public static IEnumerable<Status> Next(Status status)
{
return Flow.TryGetValue(status, out var result) ? result : Enumerable.Empty<Status>();
}
} }
} }

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

@ -81,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
Create(c); Create(c);
return EntityCreatedResult.Create(c.Data, Version); return await GetRawStateAsync();
}); });
case UpdateContent updateContent: case UpdateContent updateContent:
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}); });
case ChangeContentStatus changeContentStatus: case ChangeContentStatus changeContentStatus:
return UpdateAsync(changeContentStatus, async c => return UpdateReturnAsync(changeContentStatus, async c =>
{ {
try try
{ {
@ -157,6 +157,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
throw; throw;
} }
} }
return await GetRawStateAsync();
}); });
case DeleteContent deleteContent: case DeleteContent deleteContent:
@ -172,11 +174,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
}); });
case DiscardChanges discardChanges: case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c => return UpdateReturnAsync(discardChanges, async c =>
{ {
GuardContent.CanDiscardChanges(Snapshot.IsPending, c); GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c); DiscardChanges(c);
return await GetRawStateAsync();
}); });
default: default:
@ -220,7 +224,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
return new ContentDataChangedResult(newData, Version); return Snapshot;
} }
public void Create(CreateContent command) public void Create(CreateContent command)
@ -305,6 +309,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return operationContext; return operationContext;
} }
public Task<IContentEntity> GetRawStateAsync()
{
return Task.FromResult<IContentEntity>(Snapshot);
}
public Task<J<IContentEntity>> GetStateAsync(long version = EtagVersion.Any) public Task<J<IContentEntity>> GetStateAsync(long version = EtagVersion.Any)
{ {
return J.AsTask<IContentEntity>(GetSnapshot(version)); return J.AsTask<IContentEntity>(GetSnapshot(version));

5
src/Squidex.Shared/Permissions.cs

@ -115,11 +115,8 @@ namespace Squidex.Shared
public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read";
public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard";
public const string AppContentsArchive = "squidex.apps.{app}.contents.{name}.archive";
public const string AppContentsRestore = "squidex.apps.{app}.contents.{name}.restore";
public const string AppContentsPublish = "squidex.apps.{app}.contents.{name}.publish";
public const string AppContentsUnpublish = "squidex.apps.{app}.contents.{name}.unpublish";
public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete";
public const string AppApi = "squidex.apps.{app}.api"; public const string AppApi = "squidex.apps.{app}.api";

164
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -10,8 +10,6 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using NodaTime;
using NodaTime.Text;
using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
@ -127,7 +125,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var result = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); var result = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids);
var response = ContentsDto.FromContents(result, context, this, app); var response = ContentsDto.FromContents(result, context, this, app, null);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{ {
@ -163,9 +161,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
var result = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); var result = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var response = ContentsDto.FromContents(result, context, this, app); var response = ContentsDto.FromContents(result, context, this, app, name);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) if (ShouldProvideSurrogateKeys(response))
{ {
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys();
} }
@ -197,7 +195,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context(); var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id); var content = await contentQuery.FindContentAsync(context, name, id);
var response = ContentDto.FromContent(content, context, this, app); var response = ContentDto.FromContent(content, context, this, app, name);
if (controllerOptions.Value.EnableSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
@ -233,7 +231,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = Context(); var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id, version); var content = await contentQuery.FindContentAsync(context, name, id, version);
var response = ContentDto.FromContent(content, context, this, app); var response = ContentDto.FromContent(content, context, this, app, name);
if (controllerOptions.Value.EnableSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
@ -268,17 +266,14 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
if (publish && !this.HasPermission(Permissions.AppContentsPublish, app, name)) if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{ {
return new StatusCodeResult(123); return new ForbidResult();
} }
var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish };
var context = await CommandBus.PublishAsync(command); var response = await InvokeCommandAsync(app, name, command);
var result = context.Result<EntityCreatedResult<NamedContentData>>();
var response = ContentDto.FromCommand(command, result);
return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response); return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response);
} }
@ -308,10 +303,8 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var response = await InvokeCommandAsync(app, name, command);
var response = result.Data;
return Ok(response); return Ok(response);
} }
@ -341,10 +334,8 @@ namespace Squidex.Areas.Api.Controllers.Contents
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<ContentDataChangedResult>(); var response = await InvokeCommandAsync(app, name, command);
var response = result.Data;
return Ok(response); return Ok(response);
} }
@ -355,118 +346,33 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param> /// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the content item to publish.</param> /// <param name="id">The id of the content item to publish.</param>
/// <param name="dueTime">The date and time when the content should be published.</param> /// <param name="request">The status request.</param>
/// <returns> /// <returns>
/// 204 => Content published. /// 204 => Content published.
/// 404 => Content, schema or app not found. /// 404 => Content, schema or app not found.
/// 400 => Content was already published. /// 400 => Request is not valid.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs. /// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks> /// </remarks>
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/publish/")] [Route("content/{app}/{name}/{id}/status/")]
[ApiPermission(Permissions.AppContentsPublish)] [ApiPermission]
[ApiCosts(1)]
public async Task<IActionResult> PublishContent(string app, string name, Guid id, string dueTime = null)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = CreateCommand(id, Status.Published, dueTime);
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Unpublish 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 unpublish.</param>
/// <param name="dueTime">The date and time when the content should be unpublished.</param>
/// <returns>
/// 204 => Content unpublished.
/// 404 => Content, schema or app not found.
/// 400 => Content was not published.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPut]
[Route("content/{app}/{name}/{id}/unpublish/")]
[ApiPermission(Permissions.AppContentsUnpublish)]
[ApiCosts(1)]
public async Task<IActionResult> UnpublishContent(string app, string name, Guid id, string dueTime = null)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = CreateCommand(id, Status.Draft, dueTime);
await CommandBus.PublishAsync(command);
return NoContent();
}
/// <summary>
/// Archive 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 archive.</param>
/// <param name="dueTime">The date and time when the content should be archived.</param>
/// <returns>
/// 204 => Content archived.
/// 404 => Content, schema or app not found.
/// 400 => Content was already archived.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPut]
[Route("content/{app}/{name}/{id}/archive/")]
[ApiPermission(Permissions.AppContentsArchive)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> ArchiveContent(string app, string name, Guid id, string dueTime = null) public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request)
{ {
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = CreateCommand(id, Status.Archived, dueTime); if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
await CommandBus.PublishAsync(command); return new ForbidResult();
}
return NoContent();
}
/// <summary>
/// Restore 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 restore.</param>
/// <param name="dueTime">The date and time when the content should be restored.</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>
[HttpPut]
[Route("content/{app}/{name}/{id}/restore/")]
[ApiPermission(Permissions.AppContentsRestore)]
[ApiCosts(1)]
public async Task<IActionResult> RestoreContent(string app, string name, Guid id, string dueTime = null)
{
await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name);
var command = CreateCommand(id, Status.Draft, dueTime); var command = request.ToCommand(id);
await CommandBus.PublishAsync(command); var response = await InvokeCommandAsync(app, name, command);
return NoContent(); return Ok(response);
} }
/// <summary> /// <summary>
@ -521,26 +427,19 @@ namespace Squidex.Areas.Api.Controllers.Contents
var command = new DeleteContent { ContentId = id }; var command = new DeleteContent { ContentId = id };
await CommandBus.PublishAsync(command); var response = await InvokeCommandAsync(app, name, command);
return NoContent(); return Ok(response);
} }
private static ChangeContentStatus CreateCommand(Guid id, Status status, string dueTime) private async Task<ContentDto> InvokeCommandAsync(string app, string schema, ICommand command)
{ {
Instant? dt = null; var context = await CommandBus.PublishAsync(command);
if (!string.IsNullOrWhiteSpace(dueTime))
{
var parseResult = InstantPattern.General.Parse(dueTime);
if (parseResult.Success) var result = context.Result<IContentEntity>();
{ var response = ContentDto.FromContent(result, null, this, app, schema);
dt = parseResult.Value;
}
}
return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; return response;
} }
private QueryContext Context() private QueryContext Context()
@ -551,5 +450,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
.WithLanguages(Request.Headers["X-Languages"]) .WithLanguages(Request.Headers["X-Languages"])
.WithUnpublished(Request.Headers.ContainsKey("X-Unpublished")); .WithUnpublished(Request.Headers.ContainsKey("X-Unpublished"));
} }
private bool ShouldProvideSurrogateKeys(ContentsDto response)
{
return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys;
}
} }
} }

6
src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs

@ -178,8 +178,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
operation.Summary = $"Publish a {schemaName} content."; operation.Summary = $"Publish a {schemaName} content.";
operation.AddResponse("204", $"{schemaName} content published."); operation.AddResponse("204", $"{schemaName} content published.");
AddSecurity(operation, Permissions.AppContentsPublish);
}); });
} }
@ -191,8 +189,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
operation.Summary = $"Unpublish a {schemaName} content."; operation.Summary = $"Unpublish a {schemaName} content.";
operation.AddResponse("204", $"{schemaName} content unpublished."); operation.AddResponse("204", $"{schemaName} content unpublished.");
AddSecurity(operation, Permissions.AppContentsUnpublish);
}); });
} }
@ -217,8 +213,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
operation.Summary = $"Restore a {schemaName} content."; operation.Summary = $"Restore a {schemaName} content.";
operation.AddResponse("204", $"{schemaName} content restored."); operation.AddResponse("204", $"{schemaName} content restored.");
AddSecurity(operation, Permissions.AppContentsRestore);
}); });
} }

23
src/Squidex/Areas/Api/Controllers/Contents/Helper.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
namespace Squidex.Areas.Api.Controllers.Contents
{
public static class Helper
{
public static Permission StatusPermission(string app, string schema, Status status)
{
var id = Permissions.AppContentsStatus.Replace("{status}", status.ToString());
return Permissions.ForApp(id, app, schema);
}
}
}

34
src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.ComponentModel.DataAnnotations;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public sealed class ChangeStatusDto
{
/// <summary>
/// The new status.
/// </summary>
[Required]
public Status Status { get; set; }
/// <summary>
/// The due time.
/// </summary>
public Instant? DueTime { get; set; }
public ChangeContentStatus ToCommand(Guid id)
{
return new ChangeContentStatus { ContentId = id, Status = Status, DueTime = DueTime };
}
}
}

77
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -12,10 +12,9 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Contents.Models namespace Squidex.Areas.Api.Controllers.Contents.Models
@ -80,30 +79,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary> /// </summary>
public long Version { get; set; } public long Version { get; set; }
public static ContentDto FromCommand(CreateContent command, EntityCreatedResult<NamedContentData> result) public static ContentDto FromContent(IContentEntity content, QueryContext context, ApiController controller, string app, string schema)
{
var now = SystemClock.Instance.GetCurrentInstant();
var response = new ContentDto
{
Id = command.ContentId,
Data = result.IdOrValue,
Version = result.Version,
Created = now,
CreatedBy = command.Actor,
LastModified = now,
LastModifiedBy = command.Actor,
Status = command.Publish ? Status.Published : Status.Draft
};
return response;
}
public static ContentDto FromContent(IContentEntity content, QueryContext context, ApiController controller, string app)
{ {
var response = SimpleMapper.Map(content, new ContentDto()); var response = SimpleMapper.Map(content, new ContentDto());
if (context.Flatten) if (context?.Flatten == true)
{ {
response.Data = content.Data?.ToFlatten(); response.Data = content.Data?.ToFlatten();
response.DataDraft = content.DataDraft?.ToFlatten(); response.DataDraft = content.DataDraft?.ToFlatten();
@ -119,11 +99,58 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto()); response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto());
} }
return response.CreateLinks(controller, app); return response.CreateLinks(controller, app, schema);
} }
private ContentDto CreateLinks(ApiController controller, object app) private ContentDto CreateLinks(ApiController controller, string app, string schema)
{ {
var values = new { app, name = schema, id = Id };
AddSelfLink(controller.Url<ContentsController>(x => nameof(x.GetContent), values));
if (Version > 0)
{
var versioned = new { app, name = schema, id = Id, version = Version - 1 };
AddGetLink("prev", controller.Url<ContentsController>(x => nameof(x.GetContentVersion), versioned));
}
if (IsPending)
{
if (controller.HasPermission(Permissions.AppContentsDiscard, app, schema))
{
AddPutLink("changes/discard", controller.Url<ContentsController>(x => nameof(x.DiscardChanges), values));
}
if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published)))
{
AddPutLink("changes/publish", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values));
}
}
if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema))
{
AddPutLink("update", controller.Url<ContentsController>(x => nameof(x.PutContent), values));
if (Status == Status.Published)
{
AddPutLink("update/change", controller.Url<ContentsController>(x => nameof(x.PutContent), values) + "?asDraft");
}
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddPutLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
foreach (var next in StatusFlow.Next(Status))
{
if (controller.HasPermission(Helper.StatusPermission(app, schema, next)))
{
AddPutLink($"status/{next}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values));
}
}
return this; return this;
} }
} }

33
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -7,9 +7,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Contents.Models namespace Squidex.Areas.Api.Controllers.Contents.Models
@ -36,30 +38,47 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
return Items.ToSurrogateKeys(); return Items.ToSurrogateKeys();
} }
public static ContentsDto FromContents(IList<IContentEntity> contents, QueryContext context, ApiController controller, string app) public static ContentsDto FromContents(IList<IContentEntity> contents, QueryContext context, ApiController controller, string app, string schema)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
Total = contents.Count, Total = contents.Count,
Items = contents.Select(x => ContentDto.FromContent(x, context, controller, app)).ToArray() Items = contents.Select(x => ContentDto.FromContent(x, context, controller, app, schema)).ToArray()
}; };
return result.CreateLinks(controller, app); return result.CreateLinks(controller, app, schema);
} }
public static ContentsDto FromContents(IResultList<IContentEntity> contents, QueryContext context, ApiController controller, string app) public static ContentsDto FromContents(IResultList<IContentEntity> contents, QueryContext context, ApiController controller, string app, string schema)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
Total = contents.Total, Total = contents.Total,
Items = contents.Select(x => ContentDto.FromContent(x, context, controller, app)).ToArray() Items = contents.Select(x => ContentDto.FromContent(x, context, controller, app, schema)).ToArray()
}; };
return result.CreateLinks(controller, app); return result.CreateLinks(controller, app, schema);
} }
private ContentsDto CreateLinks(ApiController controller, string app) private ContentsDto CreateLinks(ApiController controller, string app, string schema)
{ {
if (schema != null)
{
var values = new { app, name = schema };
AddSelfLink(controller.Url<ContentsController>(x => nameof(x.GetContents), values));
if (controller.HasPermission(Permissions.AppContentsCreate, app, schema))
{
AddPostLink("create", controller.Url<ContentsController>(x => nameof(x.PostContent), values));
if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published)))
{
AddPostLink("create/publish", controller.Url<ContentsController>(x => nameof(x.PostContent), values) + "?publish=true");
}
}
}
return this; return this;
} }
} }

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

@ -7,24 +7,21 @@
<i class="icon-angle-left"></i> <i class="icon-angle-left"></i>
</a> </a>
<ng-container *ngIf="!content"> <ng-container *ngIf="!content else notNewTitle">
New Content New Content
</ng-container> </ng-container>
<ng-container *ngIf="content && content.status !== 'Archived'"> <ng-template #notNewTitle>
Edit Content Content
</ng-container> </ng-template>
<ng-container *ngIf="content && content.status === 'Archived'">
Show Content
</ng-container>
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<ng-container *ngIf="!content; else notNew"> <ng-container *ngIf="!content; else notNew">
<button type="button" class="btn btn-secondary" (click)="save()"> <button type="button" class="btn btn-secondary" (click)="save()" *ngIf="contentsState.links | async | sqxHasLink:'create'">
Save as Draft Save
</button> </button>
<button type="submit" class="btn btn-primary ml-1" title="CTRL + S"> <button type="submit" class="btn btn-primary ml-1" title="CTRL + S" *ngIf="contentsState.links | async | sqxHasLink:'create/publish'">
Save and Publish Save and Publish
</button> </button>
@ -48,35 +45,25 @@
<ng-container *ngIf="content.isPending || !schema.isSingleton"> <ng-container *ngIf="content.isPending || !schema.isSingleton">
<div class="dropdown-menu" *sqxModalView="dropdown;closeAlways:true" [sqxModalTarget]="optionsButton" @fade> <div class="dropdown-menu" *sqxModalView="dropdown;closeAlways:true" [sqxModalTarget]="optionsButton" @fade>
<ng-container *ngIf="content.isPending"> <a class="dropdown-item" (click)="discardChanges()" *ngIf="content | sqxHasLink:'changes/discard'">
<a class="dropdown-item" (click)="discardChanges()"> Discard changes
Discard changes </a>
</a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" (click)="publishChanges()"> <a class="dropdown-item" (click)="publishChanges()" *ngIf="content | sqxHasLink:'changes/publish'">
Publish changes Publish changes
</a> </a>
</ng-container>
<ng-container *ngIf="!schema.isSingleton"> <ng-container *ngIf="!schema.isSingleton">
<a class="dropdown-item" (click)="publish()" *ngIf="content.status === 'Draft'"> <a class="dropdown-item" *ngFor="let status of content.statusUpdates" (click)="changeStatus(status)">
Publish Status to {{status}}
</a>
<a class="dropdown-item" (click)="unpublish()" *ngIf="content.status === 'Published'">
Unpublish
</a>
<a class="dropdown-item" (click)="archive()" *ngIf="content.status !== 'Archived'">
Archive
</a>
<a class="dropdown-item" (click)="restore()" *ngIf="content.status === 'Archived'">
Restore
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete" <a class="dropdown-item dropdown-item-delete"
[class.disabled]="content | sqxHasNoLink:'delete'"
(sqxConfirmClick)="delete()" (sqxConfirmClick)="delete()"
confirmTitle="Delete content" confirmTitle="Delete content"
confirmText="Do you really want to delete the content?"> confirmText="Do you really want to delete the content?">
@ -88,12 +75,12 @@
</div> </div>
<ng-container *ngIf="content.status !== 'Archived'"> <ng-container *ngIf="content | sqxHasLink:'update'">
<button type="button" class="btn btn-secondary ml-1" (click)="saveAsProposal()" *ngIf="content.status === 'Published'"> <button type="button" class="btn btn-secondary ml-1" (click)="saveAsProposal()" *ngIf="content | sqxHasLink:'update/change'">
Save as Draft Save as Proposal
</button> </button>
<button type="submit" class="btn btn-primary ml-1" title="CTRL + S"> <button type="submit" class="btn btn-primary ml-1" title="CTRL + S" *ngIf="content | sqxHasLink:'update'">
Save Save
</button> </button>

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

@ -24,6 +24,7 @@ import {
EditContentForm, EditContentForm,
fadeAnimation, fadeAnimation,
FieldDto, FieldDto,
hasAnyLink,
ImmutableArray, ImmutableArray,
LanguagesState, LanguagesState,
MessageBus, MessageBus,
@ -64,7 +65,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
constructor(apiUrl: ApiUrlConfig, authService: AuthService, constructor(apiUrl: ApiUrlConfig, authService: AuthService,
public readonly appsState: AppsState, public readonly appsState: AppsState,
private readonly contentsState: ContentsState, public readonly contentsState: ContentsState,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly languagesState: LanguagesState, private readonly languagesState: LanguagesState,
private readonly messageBus: MessageBus, private readonly messageBus: MessageBus,
@ -174,29 +175,13 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
} }
private loadContent(data: any) { private loadContent(data: any) {
this.contentForm.loadContent(data, this.content && this.content.status === 'Archived'); this.contentForm.loadContent(data, this.content && !hasAnyLink(this.content, 'update'));
} }
public discardChanges() { public discardChanges() {
this.contentsState.discardChanges(this.content); this.contentsState.discardChanges(this.content);
} }
public publish() {
this.changeContentItems('Publish', 'Published');
}
public unpublish() {
this.changeContentItems('Unpublish', 'Draft');
}
public archive() {
this.changeContentItems('Archive', 'Archived');
}
public restore() {
this.changeContentItems('Restore', 'Draft');
}
public delete() { public delete() {
this.contentsState.deleteMany([this.content]).pipe(onErrorResumeNext()) this.contentsState.deleteMany([this.content]).pipe(onErrorResumeNext())
.subscribe(() => { .subscribe(() => {
@ -210,9 +195,9 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
.subscribe(); .subscribe();
} }
private changeContentItems(action: string, status: string) { public changeStatus(status: string) {
this.dueTimeSelector.selectDueTime(action).pipe( this.dueTimeSelector.selectDueTime(status).pipe(
switchMap(d => this.contentsState.changeStatus(this.content, action, status, d)), onErrorResumeNext()) switchMap(d => this.contentsState.changeStatus(this.content, status, d)), onErrorResumeNext())
.subscribe(); .subscribe();
} }

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

@ -36,7 +36,7 @@
<div class="col-auto pl-1" *ngIf="languages.length > 1"> <div class="col-auto pl-1" *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons" (selectedLanguageChange)="selectLanguage($event)" [languages]="languages.mutableValues"></sqx-language-selector> <sqx-language-selector class="languages-buttons" (selectedLanguageChange)="selectLanguage($event)" [languages]="languages.mutableValues"></sqx-language-selector>
</div> </div>
<div class="col-auto pl-1"> <div class="col-auto pl-1" *ngIf="contentsState.links | async | sqxHasLink:'create'">
<button type="button" class="btn btn-success" #newButton routerLink="new" title="New Content (CTRL + SHIFT + G)"> <button type="button" class="btn btn-success" #newButton routerLink="new" title="New Content (CTRL + SHIFT + G)">
<i class="icon-plus"></i> New <i class="icon-plus"></i> New
</button> </button>
@ -80,25 +80,13 @@
</div> </div>
<div class="selection" *ngIf="selectionCount > 0"> <div class="selection" *ngIf="selectionCount > 0">
{{selectionCount}} items selected:&nbsp;&nbsp; {{selectionCount}} items selected&nbsp;&nbsp;
<button type="button" class="btn btn-secondary mr-1" (click)="publishSelected()" *ngIf="canPublish"> <button type="button" class="btn btn-secondary mr-1" *ngFor="let status of nextStatuses" (click)="changeSelectedStatus(status)">
Publish Status to {{status}}
</button> </button>
<button type="button" class="btn btn-secondary mr-1" (click)="unpublishSelected()" *ngIf="canUnpublish"> <button type="button" class="btn btn-danger" *ngIf="selectionCanDelete"
Unpublish
</button>
<button type="button" class="btn btn-secondary mr-1" (click)="archiveSelected()" *ngIf="(contentsState.isArchive | async) === false">
Archive
</button>
<button type="button" class="btn btn-secondary mr-1" (click)="restoreSelected()" *ngIf="contentsState.isArchive | async">
Restore
</button>
<button type="button" class="btn btn-danger"
(sqxConfirmClick)="deleteSelected()" (sqxConfirmClick)="deleteSelected()"
confirmTitle="Delete content" confirmTitle="Delete content"
confirmText="Do you really want to delete the selected content items?"> confirmText="Do you really want to delete the selected content items?">
@ -115,10 +103,7 @@
[schema]="schema" [schema]="schema"
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)" (selectedChange)="selectItem(content, $event)"
(unpublish)="unpublish(content)" (statusChange)="changeStatus(content, $event)"
(publish)="publish(content)"
(archive)="archive(content)"
(restore)="restore(content)"
(delete)="delete(content)" (delete)="delete(content)"
(clone)="clone(content)"> (clone)="clone(content)">
</tr> </tr>

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

@ -16,6 +16,7 @@ import {
ContentQueryStatus, ContentQueryStatus,
ContentsState, ContentsState,
FilterState, FilterState,
hasAnyLink,
ImmutableArray, ImmutableArray,
LanguagesState, LanguagesState,
ModalModel, ModalModel,
@ -43,9 +44,9 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
public selectedItems: { [id: string]: boolean; } = {}; public selectedItems: { [id: string]: boolean; } = {};
public selectionCount = 0; public selectionCount = 0;
public selectionCanDelete = false;
public canUnpublish = false; public nextStatuses: string[] = [];
public canPublish = false;
public statuses = CONTENT_STATUSES; public statuses = CONTENT_STATUSES;
@ -118,36 +119,12 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.contentsState.deleteMany([content]); this.contentsState.deleteMany([content]);
} }
public publish(content: ContentDto) { public changeStatus(content: ContentDto, status: string) {
this.changeContentItems([content], 'Publish'); this.changeContentItems([content], status);
} }
public publishSelected() { public changeSelectedStatus(status: string) {
this.changeContentItems(this.selectItems(c => c.status !== 'Published'), 'Publish'); this.changeContentItems(this.selectItems(c => c.status !== status), status);
}
public unpublish(content: ContentDto) {
this.changeContentItems([content], 'Unpublish');
}
public unpublishSelected() {
this.changeContentItems(this.selectItems(c => c.status === 'Published'), 'Unpublish');
}
public archive(content: ContentDto) {
this.changeContentItems([content], 'Archive');
}
public archiveSelected() {
this.changeContentItems(this.selectItems(), 'Archive');
}
public restore(content: ContentDto) {
this.changeContentItems([content], 'Restore');
}
public restoreSelected() {
this.changeContentItems(this.selectItems(), 'Restore');
} }
public clone(content: ContentDto) { public clone(content: ContentDto) {
@ -234,25 +211,35 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.isAllSelected = this.contentsState.snapshot.contents.length > 0; this.isAllSelected = this.contentsState.snapshot.contents.length > 0;
this.selectionCount = 0; this.selectionCount = 0;
this.selectionCanDelete = true;
const allActions = {};
this.canPublish = true; for (let content of this.contentsState.snapshot.contents.values) {
this.canUnpublish = true; for (let status of content.statusUpdates) {
allActions[status] = true;
}
}
for (let content of this.contentsState.snapshot.contents.values) { for (let content of this.contentsState.snapshot.contents.values) {
if (this.selectedItems[content.id]) { if (this.selectedItems[content.id]) {
this.selectionCount++; this.selectionCount++;
if (content.status !== 'Published') { for (let action in allActions) {
this.canUnpublish = false; if (!content.statusUpdates) {
delete allActions[action];
}
} }
if (content.status === 'Published') { if (!hasAnyLink(content, 'delete')) {
this.canPublish = false; this.selectionCanDelete = false;
} }
} else { } else {
this.isAllSelected = false; this.isAllSelected = false;
} }
} }
this.nextStatuses = Object.keys(allActions);
} }
} }

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

@ -41,16 +41,7 @@ export class ContentItemComponent implements OnChanges {
public delete = new EventEmitter(); public delete = new EventEmitter();
@Output() @Output()
public archive = new EventEmitter(); public statusChange = new EventEmitter<string>();
@Output()
public restore = new EventEmitter();
@Output()
public publish = new EventEmitter();
@Output()
public unpublish = new EventEmitter();
@Output() @Output()
public selectedChange = new EventEmitter(); public selectedChange = new EventEmitter();
@ -132,20 +123,8 @@ export class ContentItemComponent implements OnChanges {
this.delete.emit(); this.delete.emit();
} }
public emitPublish() { public emitChangeStatus(status: string) {
this.publish.emit(); this.statusChange.emit(status);
}
public emitUnpublish() {
this.unpublish.emit();
}
public emitArchive() {
this.archive.emit();
}
public emitRestore() {
this.unpublish.emit();
} }
public emitClone() { public emitClone() {

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

@ -15,6 +15,7 @@ import {
ContentsDto, ContentsDto,
ContentsService, ContentsService,
DateTime, DateTime,
Resource,
ScheduleDto, ScheduleDto,
Version, Version,
Versioned Versioned
@ -57,56 +58,16 @@ describe('ContentsService', () => {
req.flush({ req.flush({
total: 10, total: 10,
items: [ items: [
{ contentResponse(12),
id: 'id1', contentResponse(13)
status: 'Published',
created: '2016-12-12T10:10',
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
scheduleJob: {
status: 'Draft',
scheduledBy: 'Scheduler1',
dueTime: '2018-12-12T10:10'
},
isPending: true,
version: 11,
data: {},
dataDraft: {}
},
{
id: 'id2',
status: 'Published',
created: '2016-10-12T10:10',
createdBy: 'Created2',
lastModified: '2017-10-12T10:10',
lastModifiedBy: 'LastModifiedBy2',
version: 22,
data: {},
dataDraft: {}
}
] ]
}); });
expect(contents!).toEqual( expect(contents!).toEqual(
new ContentsDto(10, [ new ContentsDto(10, [
new ContentDto('id1', 'Published', createContent(12),
DateTime.parseISO_UTC('2016-12-12T10:10'), 'Created1', createContent(13)
DateTime.parseISO_UTC('2017-12-12T10:10'), 'LastModifiedBy1', ]));
new ScheduleDto('Draft', 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10')),
true,
{},
{},
new Version('11')),
new ContentDto('id2', 'Published',
DateTime.parseISO_UTC('2016-10-12T10:10'), 'Created2',
DateTime.parseISO_UTC('2017-10-12T10:10'), 'LastModifiedBy2',
null,
false,
{},
{},
new Version('22'))
]));
})); }));
it('should append query to get request as search', it('should append query to get request as search',
@ -162,36 +123,9 @@ describe('ContentsService', () => {
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush(createContent(12));
id: 'id1',
status: 'Published',
created: '2016-12-12T10:10',
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
scheduleJob: {
status: 'Draft',
scheduledBy: 'Scheduler1',
dueTime: '2018-12-12T10:10'
},
isPending: true,
data: {},
dataDraft: {}
}, {
headers: {
etag: '2'
}
});
expect(content!).toEqual( expect(content!).toEqual(createContent(12));
new ContentDto('id1', 'Published',
DateTime.parseISO_UTC('2016-12-12T10:10'), 'Created1',
DateTime.parseISO_UTC('2017-12-12T10:10'), 'LastModifiedBy1',
new ScheduleDto('Draft', 'Scheduler1', DateTime.parseISO_UTC('2018-12-12T10:10')),
true,
{},
{},
new Version('2')));
})); }));
it('should make post request to create content', it('should make post request to create content',
@ -210,30 +144,9 @@ describe('ContentsService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush(createContent(12));
id: 'id1',
status: 'Published',
created: '2016-12-12T10:10',
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
isPending: false,
data: {}
}, {
headers: {
etag: '2'
}
});
expect(content!).toEqual( expect(content!).toEqual(createContent(12));
new ContentDto('id1', 'Published',
DateTime.parseISO_UTC('2016-12-12T10:10'), 'Created1',
DateTime.parseISO_UTC('2017-12-12T10:10'), 'LastModifiedBy1',
null,
false,
null,
{},
new Version('2')));
})); }));
it('should make get request to get versioned content data', it('should make get request to get versioned content data',
@ -262,14 +175,26 @@ describe('ContentsService', () => {
const dto = {}; const dto = {};
contentsService.putContent('my-app', 'my-schema', 'content1', dto, true, version).subscribe(); const resource: Resource = {
_links: {
['update/change']: { method: 'PUT', href: '/api/content/my-app/my-schema/content1?asDraft=true' }
}
};
let content: ContentDto;
contentsService.putContent('my-app', resource, dto, true, version).subscribe(result => {
content = result;
});
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?asDraft=true'); const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1?asDraft=true');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush({}); req.flush(createContent(12));
expect(content!).toEqual(createContent(12));
})); }));
it('should make patch request to update content', it('should make patch request to update content',
@ -277,61 +202,88 @@ describe('ContentsService', () => {
const dto = {}; const dto = {};
contentsService.patchContent('my-app', 'my-schema', 'content1', dto, version).subscribe(); const resource: Resource = {
_links: {
patch: { method: 'PATCH', href: '/api/content/my-app/my-schema/content1' }
}
};
let content: ContentDto;
contentsService.patchContent('my-app', resource, dto, version).subscribe(result => {
content = result;
});
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');
expect(req.request.method).toEqual('PATCH'); expect(req.request.method).toEqual('PATCH');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush({}); req.flush(createContent(12));
expect(content!).toEqual(createContent(12));
})); }));
it('should make put request to discard changes', it('should make put request to discard changes',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.discardChanges('my-app', 'my-schema', 'content1', version).subscribe(); const resource: Resource = {
_links: {
discard: { method: 'PUT', href: '/api/content/my-app/my-schema/content1/discard' }
}
};
let content: ContentDto;
contentsService.discardChanges('my-app', resource, version).subscribe(result => {
content = result;
});
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/discard'); const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/discard');
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);
req.flush({}); req.flush(createContent(12));
expect(content!).toEqual(createContent(12));
})); }));
it('should make put request to change content status', it('should make put request to change content status',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.changeContentStatus('my-app', 'my-schema', 'content1', 'publish', null, version).subscribe(); const resource: Resource = {
_links: {
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/publish'); ['status/published']: { method: 'PUT', href: '/api/content/my-app/my-schema/content1/status' }
}
expect(req.request.method).toEqual('PUT'); };
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
}));
it('should make put request with due time when status change is scheduled',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
const dueTime = '2016-12-12T10:10:00'; let content: ContentDto;
contentsService.changeContentStatus('my-app', 'my-schema', 'content1', 'publish', dueTime, version).subscribe(); contentsService.putStatus('my-app', resource, 'published', '2016-12-12T10:10:00', version).subscribe(result => {
content = result;
});
const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/publish?dueTime=2016-12-12T10:10:00'); const req = httpMock.expectOne('http://service/p/api/content/my-app/my-schema/content1/status');
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({}); req.flush(createContent(12));
expect(content!).toEqual(createContent(12));
})); }));
it('should make delete request to delete content', it('should make delete request to delete content',
inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => { inject([ContentsService, HttpTestingController], (contentsService: ContentsService, httpMock: HttpTestingController) => {
contentsService.deleteContent('my-app', 'my-schema', 'content1', version).subscribe(); const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/content/my-app/my-schema/content1' }
}
};
contentsService.deleteContent('my-app', resource, 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');
@ -340,4 +292,44 @@ describe('ContentsService', () => {
req.flush({}); req.flush({});
})); }));
});
function contentResponse(id: number) {
return {
id: `id${id}`,
status: `Status${id}`,
created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
lastModifiedBy: `modifier-${id}`,
scheduleJob: {
status: 'Draft',
scheduledBy: `Scheduler${id}`,
dueTime: `${id % 1000 + 2000}-11-11T10:10:00`
},
isPending: true,
data: {},
dataDraft: {},
version: id,
_links: {
update: { method: 'PUT', href: `/contents/id${id}` }
}
};
}
});
export function createContent(id: number, suffix = '') {
const result = new ContentDto(
`id${id}`,
`Status${id}${suffix}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`,
new ScheduleDto('Draft', `Scheduler${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`)),
true,
{},
{},
new Version(`${id}`));
result._links['update'] = { method: 'PUT', href: `/contents/id${id}` };
return result;
}

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

@ -16,26 +16,33 @@ import {
DateTime, DateTime,
HTTP, HTTP,
mapVersioned, mapVersioned,
Model,
pretifyError, pretifyError,
Resource,
ResourceLinks,
ResultSet, ResultSet,
Version, Version,
Versioned Versioned,
withLinks
} from '@app/framework'; } from '@app/framework';
export class ScheduleDto extends Model<ScheduleDto> { export class ScheduleDto {
constructor( constructor(
public readonly status: string, public readonly status: string,
public readonly scheduledBy: string, public readonly scheduledBy: string,
public readonly dueTime: DateTime public readonly dueTime: DateTime
) { ) {
super();
} }
} }
export class ContentsDto extends ResultSet<ContentDto> { } export class ContentsDto extends ResultSet<ContentDto> {
public readonly _links: ResourceLinks = {};
}
export class ContentDto {
public readonly _links: ResourceLinks = {};
public readonly statusUpdates: string[] = [];
export class ContentDto extends Model<ContentDto> {
constructor( constructor(
public readonly id: string, public readonly id: string,
public readonly status: string, public readonly status: string,
@ -49,11 +56,6 @@ export class ContentDto extends Model<ContentDto> {
public readonly dataDraft: object, public readonly dataDraft: object,
public readonly version: Version public readonly version: Version
) { ) {
super();
}
public with(value: Partial<ContentDto>): ContentDto {
return this.clone(value);
} }
} }
@ -107,24 +109,9 @@ export class ContentsService {
const items: any[] = body.items; const items: any[] = body.items;
const contents = new ContentsDto(body.total, items.map(item => const contents = items.map(x => parseContent(x));
new ContentDto(
item.id, return withLinks(new ContentsDto(body.total, contents), body);
item.status,
DateTime.parseISO_UTC(item.created), item.createdBy,
DateTime.parseISO_UTC(item.lastModified), item.lastModifiedBy,
item.scheduleJob
? new ScheduleDto(
item.scheduleJob.status,
item.scheduleJob.scheduledBy,
DateTime.parseISO_UTC(item.scheduleJob.dueTime))
: null,
item.isPending === true,
item.data,
item.dataDraft,
new Version(item.version.toString()))));
return contents;
}), }),
pretifyError('Failed to load contents. Please reload.')); pretifyError('Failed to load contents. Please reload.'));
} }
@ -133,26 +120,8 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`);
return HTTP.getVersioned(this.http, url).pipe( return HTTP.getVersioned(this.http, url).pipe(
map(({ version, payload }) => { map(({ payload }) => {
const body = payload.body; return parseContent(payload.body);
const content = new ContentDto(
body.id,
body.status,
DateTime.parseISO_UTC(body.created), body.createdBy,
DateTime.parseISO_UTC(body.lastModified), body.lastModifiedBy,
body.scheduleJob
? new ScheduleDto(
body.scheduleJob.status,
body.scheduleJob.scheduledBy,
DateTime.parseISO_UTC(body.scheduleJob.dueTime))
: null,
body.isPending === true,
body.data,
body.dataDraft,
version);
return content;
}), }),
pretifyError('Failed to load content. Please reload.')); pretifyError('Failed to load content. Please reload.'));
} }
@ -171,21 +140,8 @@ export class ContentsService {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?publish=${publish}`); const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?publish=${publish}`);
return HTTP.postVersioned(this.http, url, dto).pipe( return HTTP.postVersioned(this.http, url, dto).pipe(
map(({ version, payload }) => { map(({ payload }) => {
const body = payload.body; return parseContent(payload.body);
const content = new ContentDto(
body.id,
body.status,
DateTime.parseISO_UTC(body.created), body.createdBy,
DateTime.parseISO_UTC(body.lastModified), body.lastModifiedBy,
null,
body.isPending,
null,
body.data,
version);
return content;
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Created', appName); this.analytics.trackEvent('Content', 'Created', appName);
@ -193,12 +149,14 @@ 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, asDraft: boolean, version: Version): Observable<Versioned<any>> { public putContent(appName: string, resource: Resource, dto: any, asDraft: boolean, version: Version): Observable<ContentDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}?asDraft=${asDraft}`); const link = resource._links[asDraft ? 'update/change' : 'update'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.putVersioned(this.http, url, dto, version).pipe( return HTTP.putVersioned(this.http, url, dto, version).pipe(
mapVersioned(payload => { map(({ payload }) => {
return payload.body; return parseContent(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Updated', appName); this.analytics.trackEvent('Content', 'Updated', appName);
@ -206,12 +164,14 @@ export class ContentsService {
pretifyError('Failed to update content. Please reload.')); pretifyError('Failed to update content. Please reload.'));
} }
public patchContent(appName: string, schemaName: string, id: string, dto: any, version: Version): Observable<Versioned<any>> { public patchContent(appName: string, resource: Resource, dto: any, version: Version): Observable<ContentDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const link = resource._links['patch'];
return HTTP.patchVersioned(this.http, url, dto, version).pipe( const url = this.apiUrl.buildUrl(link.href);
mapVersioned(payload => {
return payload.body; return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseContent(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Updated', appName); this.analytics.trackEvent('Content', 'Updated', appName);
@ -219,37 +179,75 @@ 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>> { public discardChanges(appName: string, resource: Resource, version: Version): Observable<ContentDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/discard`); const link = resource._links['discard'];
return HTTP.putVersioned(this.http, url, {}, version).pipe( const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ payload }) => {
return parseContent(payload.body);
}),
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Discarded', appName); this.analytics.trackEvent('Content', 'Discarded', appName);
}), }),
pretifyError('Failed to discard changes. Please reload.')); pretifyError('Failed to discard changes. Please reload.'));
} }
public deleteContent(appName: string, schemaName: string, id: string, version: Version): Observable<Versioned<any>> { public putStatus(appName: string, resource: Resource, status: string, dueTime: string | null, version: Version): Observable<ContentDto> {
const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}`); const link = resource._links[`status/${status}`];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.deleteVersioned(this.http, url, version).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, { status, dueTime }).pipe(
map(({ payload }) => {
return parseContent(payload.body);
}),
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Deleted', appName); this.analytics.trackEvent('Content', 'Archived', appName);
}), }),
pretifyError('Failed to delete content. Please reload.')); pretifyError(`Failed to ${status} content. Please reload.`));
} }
public changeContentStatus(appName: string, schemaName: string, id: string, action: string, dueTime: string | null, version: Version): Observable<Versioned<any>> { public deleteContent(appName: string, resource: Resource, version: Version): Observable<Versioned<any>> {
let url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/${action}`); const link = resource._links['delete'];
if (dueTime) { const url = this.apiUrl.buildUrl(link.href);
url += `?dueTime=${dueTime}`;
}
return HTTP.putVersioned(this.http, url, {}, version).pipe( return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => { tap(() => {
this.analytics.trackEvent('Content', 'Archived', appName); this.analytics.trackEvent('Content', 'Deleted', appName);
}), }),
pretifyError(`Failed to ${action} content. Please reload.`)); pretifyError('Failed to delete content. Please reload.'));
} }
}
function parseContent(response: any) {
return nextStatuses(withLinks(
new ContentDto(
response.id,
response.status,
DateTime.parseISO_UTC(response.created), response.createdBy,
DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy,
response.scheduleJob
? new ScheduleDto(
response.scheduleJob.status,
response.scheduleJob.scheduledBy,
DateTime.parseISO_UTC(response.scheduleJob.dueTime))
: null,
response.isPending === true,
response.data,
response.dataDraft,
new Version(response.version)),
response));
}
function nextStatuses(content: ContentDto) {
const nexts = Object.keys(content._links).filter(x => x.startsWith('status/')).map(x => x.substr(7));
for (let next of nexts) {
content.statusUpdates.push(next);
}
return content;
} }

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

@ -15,18 +15,18 @@ import {
ErrorDto, ErrorDto,
ImmutableArray, ImmutableArray,
Pager, Pager,
ResourceLinks,
shareSubscribed, shareSubscribed,
State, State,
Version, Version,
Versioned Versioned
} from '@app/framework'; } from '@app/framework';
import { AuthService } from './../services/auth.service';
import { SchemaDto } from './../services/schemas.service'; import { SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
import { SchemasState } from './schemas.state'; import { SchemasState } from './schemas.state';
import { ContentDto, ContentQueryStatus, ContentsService, ScheduleDto } from './../services/contents.service'; import { ContentDto, ContentQueryStatus, ContentsService } from './../services/contents.service';
interface Snapshot { interface Snapshot {
// The current comments. // The current comments.
@ -46,6 +46,9 @@ interface Snapshot {
// The selected content. // The selected content.
selectedContent?: ContentDto | null; selectedContent?: ContentDto | null;
// The links.
links: ResourceLinks;
} }
export const CONTENT_STATUSES = { export const CONTENT_STATUSES = {
@ -87,13 +90,16 @@ export abstract class ContentsStateBase extends State<Snapshot> {
this.changes.pipe(map(x => x.status), this.changes.pipe(map(x => x.status),
distinctUntilChanged()); distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly contentsService: ContentsService, private readonly contentsService: ContentsService,
private readonly dialogs: DialogService private readonly dialogs: DialogService
) { ) {
super({ contents: ImmutableArray.of(), contentsPager: new Pager(0), status: 'PublishedDraft' }); super({ contents: ImmutableArray.of(), contentsPager: new Pager(0), status: 'PublishedDraft', links: {} });
} }
public select(id: string | null): Observable<ContentDto | null> { public select(id: string | null): Observable<ContentDto | null> {
@ -138,7 +144,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
this.snapshot.contentsPager.skip, this.snapshot.contentsPager.skip,
this.snapshot.contentsQuery, undefined, this.snapshot.contentsQuery, undefined,
this.snapshot.status).pipe( this.snapshot.status).pipe(
tap(({ total, items }) => { tap(({ total, items, _links: links }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('Contents reloaded.'); this.dialogs.notifyInfo('Contents reloaded.');
} }
@ -153,7 +159,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
selectedContent = contents.find(x => x.id === selectedContent!.id) || selectedContent; selectedContent = contents.find(x => x.id === selectedContent!.id) || selectedContent;
} }
return { ...s, contents, contentsPager, selectedContent, isLoaded: true }; return { ...s, contents, contentsPager, selectedContent, isLoaded: true, links };
}); });
})); }));
} }
@ -173,10 +179,10 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public changeManyStatus(contents: ContentDto[], action: string, dueTime: string | null): Observable<any> { public changeManyStatus(contents: ContentDto[], status: string, dueTime: string | null): Observable<any> {
return forkJoin( return forkJoin(
contents.map(c => contents.map(c =>
this.contentsService.changeContentStatus(this.appName, this.schemaName, c.id, action, dueTime, c.version).pipe( this.contentsService.putStatus(this.appName, c, status, dueTime, c.version).pipe(
catchError(error => of(error))))).pipe( catchError(error => of(error))))).pipe(
tap(results => { tap(results => {
const error = results.find(x => x instanceof ErrorDto); const error = results.find(x => x instanceof ErrorDto);
@ -194,7 +200,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public deleteMany(contents: ContentDto[]): Observable<any> { public deleteMany(contents: ContentDto[]): Observable<any> {
return forkJoin( return forkJoin(
contents.map(c => contents.map(c =>
this.contentsService.deleteContent(this.appName, this.schemaName, c.id, c.version).pipe( this.contentsService.deleteContent(this.appName, c, c.version).pipe(
catchError(error => of(error))))).pipe( catchError(error => of(error))))).pipe(
tap(results => { tap(results => {
const error = results.find(x => x instanceof ErrorDto); const error = results.find(x => x instanceof ErrorDto);
@ -209,15 +215,8 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
} }
public publishChanges(content: ContentDto, dueTime: string | null, now?: DateTime): Observable<ContentDto> { public publishChanges(content: ContentDto, dueTime: string | null): Observable<ContentDto> {
return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, 'Publish', dueTime, content.version).pipe( return this.contentsService.putStatus(this.appName, content, 'Published', dueTime, content.version).pipe(
map(({ version }) => {
if (dueTime) {
return changeScheduleStatus(content, 'Published', dueTime, this.user, version, now);
} else {
return confirmChanges(content, this.user, version, now);
}
}),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -226,15 +225,8 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public changeStatus(content: ContentDto, action: string, status: string, dueTime: string | null, now?: DateTime): Observable<ContentDto> { public changeStatus(content: ContentDto, status: string, dueTime: string | null): Observable<ContentDto> {
return this.contentsService.changeContentStatus(this.appName, this.schemaName, content.id, action, dueTime, content.version).pipe( return this.contentsService.putStatus(this.appName, content, status, dueTime, content.version).pipe(
map(({ version }) => {
if (dueTime) {
return changeScheduleStatus(content, status, dueTime, this.user, version, now);
} else {
return changeStatus(content, status, this.user, version, now);
}
}),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -244,8 +236,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
public update(content: ContentDto, request: any, now?: DateTime): Observable<ContentDto> { public update(content: ContentDto, request: any, now?: DateTime): Observable<ContentDto> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, false, content.version).pipe( return this.contentsService.putContent(this.appName, content, request, false, content.version).pipe(
map(({ payload, version }) => updateData(content, payload, this.user, version, now)),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -254,9 +245,8 @@ export abstract class ContentsStateBase extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public proposeUpdate(content: ContentDto, request: any, now?: DateTime): Observable<ContentDto> { public proposeUpdate(content: ContentDto, request: any): Observable<ContentDto> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, true, content.version).pipe( return this.contentsService.putContent(this.appName, content, request, true, content.version).pipe(
map(({ payload, version }) => updateDataDraft(content, payload, this.user, version, now)),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -266,8 +256,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
public discardChanges(content: ContentDto, now?: DateTime): Observable<ContentDto> { public discardChanges(content: ContentDto, now?: DateTime): Observable<ContentDto> {
return this.contentsService.discardChanges(this.appName, this.schemaName, content.id, content.version).pipe( return this.contentsService.discardChanges(this.appName, content, content.version).pipe(
map(({ version }) => discardChanges(content, this.user, version, now)),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -277,8 +266,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
} }
public patch(content: ContentDto, request: any, now?: DateTime): Observable<ContentDto> { public patch(content: ContentDto, request: any, now?: DateTime): Observable<ContentDto> {
return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version).pipe( return this.contentsService.patchContent(this.appName, content, request, content.version).pipe(
map(({ payload, version }) => updateData(content, payload, this.user, version, now)),
tap(updated => { tap(updated => {
this.dialogs.notifyInfo('Content updated successfully.'); this.dialogs.notifyInfo('Content updated successfully.');
@ -331,19 +319,15 @@ export abstract class ContentsStateBase extends State<Snapshot> {
return this.appsState.appName; return this.appsState.appName;
} }
private get user() {
return this.authState.user!.token;
}
protected abstract get schemaName(): string; protected abstract get schemaName(): string;
} }
@Injectable() @Injectable()
export class ContentsState extends ContentsStateBase { export class ContentsState extends ContentsStateBase {
constructor(appsState: AppsState, authState: AuthService, contentsService: ContentsService, dialogs: DialogService, constructor(appsState: AppsState, contentsService: ContentsService, dialogs: DialogService,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState
) { ) {
super(appsState, authState, contentsService, dialogs); super(appsState, contentsService, dialogs);
} }
protected get schemaName() { protected get schemaName() {
@ -356,65 +340,12 @@ export class ManualContentsState extends ContentsStateBase {
public schema: SchemaDto; public schema: SchemaDto;
constructor( constructor(
appsState: AppsState, authState: AuthService, contentsService: ContentsService, dialogs: DialogService appsState: AppsState, contentsService: ContentsService, dialogs: DialogService
) { ) {
super(appsState, authState, contentsService, dialogs); super(appsState, contentsService, dialogs);
} }
protected get schemaName() { protected get schemaName() {
return this.schema.name; return this.schema.name;
} }
} }
const changeScheduleStatus = (content: ContentDto, status: string, dueTime: string, user: string, version: Version, now?: DateTime) =>
content.with({
scheduleJob: new ScheduleDto(status, user, DateTime.parseISO_UTC(dueTime)),
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const changeStatus = (content: ContentDto, status: string, user: string, version: Version, now?: DateTime) =>
content.with({
status,
scheduleJob: null,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const updateData = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
content.with({
data,
dataDraft: data,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const updateDataDraft = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
content.with({
isPending: true,
dataDraft: data,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const confirmChanges = (content: ContentDto, user: string, version: Version, now?: DateTime) =>
content.with({
isPending: false,
data: content.dataDraft,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
const discardChanges = (content: ContentDto, user: string, version: Version, now?: DateTime) =>
content.with({
isPending: false,
dataDraft: content.data,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
Loading…
Cancel
Save