Browse Source

Merge pull request #382 from Squidex/feature/multiple_workflows

Feature/multiple workflows
pull/381/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
e8949a132f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 92
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  2. 41
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  3. 2
      src/Squidex.Domain.Apps.Core.Model/Named.cs
  4. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
  5. 10
      src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs
  6. 6
      src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs
  7. 2
      src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs
  8. 26
      src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs
  9. 42
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  10. 23
      src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs
  11. 8
      src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs
  12. 5
      src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs
  13. 37
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs
  14. 14
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  15. 2
      src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs
  16. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs
  17. 14
      src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs
  18. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs
  19. 2
      src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs
  20. 34
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  21. 3
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  22. 6
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  23. 57
      src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs
  24. 48
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  25. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  26. 40
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  27. 2
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  28. 19
      src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs
  29. 6
      src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs
  30. 6
      src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs
  31. 12
      src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  32. 20
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs
  33. 18
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs
  34. 7
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs
  35. 16
      src/Squidex.Infrastructure/CollectionExtensions.cs
  36. 12
      src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs
  37. 2
      src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs
  38. 4
      src/Squidex.Shared/Permissions.cs
  39. 4
      src/Squidex.Web/ApiExceptionFilterAttribute.cs
  40. 1
      src/Squidex.Web/ApiPermissionAttribute.cs
  41. 42
      src/Squidex.Web/Deferred.cs
  42. 61
      src/Squidex.Web/ETagExtensions.cs
  43. 27
      src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs
  44. 26
      src/Squidex.Web/Pipeline/DeferredActionFilter.cs
  45. 2
      src/Squidex.Web/Pipeline/ETagFilter.cs
  46. 2
      src/Squidex.Web/Pipeline/ETagOptions.cs
  47. 1
      src/Squidex.Web/ResourceLink.cs
  48. 7
      src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  49. 31
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  50. 7
      src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  51. 7
      src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs
  52. 16
      src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  53. 89
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  54. 8
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  55. 19
      src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs
  56. 4
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  57. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs
  58. 21
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs
  59. 53
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  60. 60
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs
  61. 43
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  62. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  63. 10
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs
  64. 2
      src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
  65. 2
      src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs
  66. 2
      src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  67. 8
      src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  68. 49
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  69. 4
      src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs
  70. 23
      src/Squidex/Areas/Api/Controllers/Contents/Helper.cs
  71. 23
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  72. 15
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  73. 5
      src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs
  74. 7
      src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  75. 4
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs
  76. 2
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs
  77. 2
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs
  78. 4
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs
  79. 5
      src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs
  80. 14
      src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  81. 4
      src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs
  82. 2
      src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs
  83. 5
      src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs
  84. 16
      src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  85. 2
      src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs
  86. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  87. 1
      src/Squidex/Config/Domain/SerializationInitializer.cs
  88. 1
      src/Squidex/Config/Web/WebServices.cs
  89. 1
      src/Squidex/WebStartup.cs
  90. 4
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  91. 1
      src/Squidex/app/features/settings/declarations.ts
  92. 4
      src/Squidex/app/features/settings/module.ts
  93. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  94. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.ts
  95. 23
      src/Squidex/app/features/settings/pages/backups/pipes.ts
  96. 4
      src/Squidex/app/features/settings/pages/clients/clients-page.component.ts
  97. 38
      src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts
  98. 4
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  99. 96
      src/Squidex/app/features/settings/pages/workflows/workflow.component.html
  100. 34
      src/Squidex/app/features/settings/pages/workflows/workflow.component.scss

92
src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -5,51 +5,75 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
{ {
public sealed class Workflow public sealed class Workflow : Named
{ {
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>(); private const string DefaultName = "Unnamed";
public static readonly Workflow Default = new Workflow( public static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
new Dictionary<Status, WorkflowStep> public static readonly IReadOnlyList<Guid> EmptySchemaIds = new List<Guid>();
{ public static readonly Workflow Default = CreateDefault();
[Status.Archived] = public static readonly Workflow Empty = new Workflow(default, EmptySteps);
new WorkflowStep(
new Dictionary<Status, WorkflowTransition> public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; } = EmptySteps;
{
[Status.Draft] = new WorkflowTransition() public IReadOnlyList<Guid> SchemaIds { get; } = EmptySchemaIds;
},
StatusColors.Archived, true),
[Status.Draft] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Published] = new WorkflowTransition()
},
StatusColors.Draft),
[Status.Published] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Published)
}, Status.Draft);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; }
public Status Initial { get; } public Status Initial { get; }
public Workflow(IReadOnlyDictionary<Status, WorkflowStep> steps, Status initial) public Workflow(
Status initial,
IReadOnlyDictionary<Status, WorkflowStep> steps,
IReadOnlyList<Guid> schemaIds = null,
string name = null)
: base(name ?? DefaultName)
{ {
Steps = steps ?? EmptySteps;
Initial = initial; Initial = initial;
if (steps != null)
{
Steps = steps;
}
if (schemaIds != null)
{
SchemaIds = schemaIds;
}
}
public static Workflow CreateDefault(string name = null)
{
return new Workflow(
Status.Draft, new Dictionary<Status, WorkflowStep>
{
[Status.Archived] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Archived, true),
[Status.Draft] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Published] = new WorkflowTransition()
},
StatusColors.Draft),
[Status.Published] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Published)
}, null, name);
} }
public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status)

41
src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
@ -27,6 +28,20 @@ namespace Squidex.Domain.Apps.Core.Contents
{ {
} }
[Pure]
public Workflows Remove(Guid id)
{
return new Workflows(Without(id));
}
[Pure]
public Workflows Add(Guid workflowId, string name)
{
Guard.NotNullOrEmpty(name, nameof(name));
return new Workflows(With(workflowId, Workflow.CreateDefault(name)));
}
[Pure] [Pure]
public Workflows Set(Workflow workflow) public Workflows Set(Workflow workflow)
{ {
@ -35,6 +50,32 @@ namespace Squidex.Domain.Apps.Core.Contents
return new Workflows(With(Guid.Empty, workflow)); return new Workflows(With(Guid.Empty, workflow));
} }
[Pure]
public Workflows Set(Guid id, Workflow workflow)
{
Guard.NotNull(workflow, nameof(workflow));
return new Workflows(With(id, workflow));
}
[Pure]
public Workflows Update(Guid id, Workflow workflow)
{
Guard.NotNull(workflow, nameof(workflow));
if (id == Guid.Empty)
{
return Set(workflow);
}
if (!ContainsKey(id))
{
return this;
}
return new Workflows(With(id, workflow));
}
public Workflow GetFirst() public Workflow GetFirst()
{ {
return Values.FirstOrDefault() ?? Workflow.Default; return Values.FirstOrDefault() ?? Workflow.Default;

2
src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs → src/Squidex.Domain.Apps.Core.Model/Named.cs

@ -7,7 +7,7 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core
{ {
public abstract class Named public abstract class Named
{ {

2
src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs

@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
if (Properties is ArrayFieldProperties arrayProperties) if (Properties is ArrayFieldProperties arrayProperties)
{ {
var nested = Children?.ToArray(n => n.ToNestedField()) ?? Array.Empty<NestedField>(); var nested = Children?.Map(n => n.ToNestedField()) ?? Array.Empty<NestedField>();
return new ArrayField(Id, Name, partitioning, nested, arrayProperties, this); return new ArrayField(Id, Name, partitioning, nested, arrayProperties, this);
} }

10
src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs

@ -49,7 +49,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
SimpleMapper.Map(schema, this); SimpleMapper.Map(schema, this);
Fields = Fields =
schema.Fields.ToArray(x => schema.Fields.Select(x =>
new JsonFieldModel new JsonFieldModel
{ {
Id = x.Id, Id = x.Id,
@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
IsDisabled = x.IsDisabled, IsDisabled = x.IsDisabled,
Partitioning = x.Partitioning.Key, Partitioning = x.Partitioning.Key,
Properties = x.RawProperties Properties = x.RawProperties
}); }).ToArray();
PreviewUrls = schema.PreviewUrls.ToDictionary(x => x.Key, x => x.Value); PreviewUrls = schema.PreviewUrls.ToDictionary(x => x.Key, x => x.Value);
} }
@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
{ {
if (field is ArrayField arrayField) if (field is ArrayField arrayField)
{ {
return arrayField.Fields.ToArray(x => return arrayField.Fields.Select(x =>
new JsonNestedFieldModel new JsonNestedFieldModel
{ {
Id = x.Id, Id = x.Id,
@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
IsLocked = x.IsLocked, IsLocked = x.IsLocked,
IsDisabled = x.IsDisabled, IsDisabled = x.IsDisabled,
Properties = x.RawProperties Properties = x.RawProperties
}); }).ToArray();
} }
return null; return null;
@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
public Schema ToSchema() public Schema ToSchema()
{ {
var fields = Fields.ToArray(f => f.ToField()) ?? Array.Empty<RootField>(); var fields = Fields.Map(f => f.ToField()) ?? Array.Empty<RootField>();
var schema = new Schema(Name, fields, Properties, IsPublished, IsSingleton); var schema = new Schema(Name, fields, Properties, IsPublished, IsSingleton);

6
src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs

@ -19,11 +19,11 @@ namespace Squidex.Domain.Apps.Core.Tags
Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string group, HashSet<string> ids); Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string group, HashSet<string> ids);
Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string group); Task<TagsSet> GetTagsAsync(Guid appId, string group);
Task<TagSet> GetExportableTagsAsync(Guid appId, string group); Task<TagsExport> GetExportableTagsAsync(Guid appId, string group);
Task RebuildTagsAsync(Guid appId, string group, TagSet tags); Task RebuildTagsAsync(Guid appId, string group, TagsExport tags);
Task ClearAsync(Guid appId, string group); Task ClearAsync(Guid appId, string group);
} }

2
src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs → src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs

@ -9,7 +9,7 @@ using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Tags namespace Squidex.Domain.Apps.Core.Tags
{ {
public sealed class TagSet : Dictionary<string, Tag> public sealed class TagsExport : Dictionary<string, Tag>
{ {
} }
} }

26
src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Tags
{
public sealed class TagsSet : Dictionary<string, int>
{
public long Version { get; set; }
public TagsSet()
{
}
public TagsSet(IDictionary<string, int> tags, long version)
: base(tags)
{
Version = version;
}
}
}

42
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -119,12 +119,32 @@ namespace Squidex.Domain.Apps.Entities.Apps
return Snapshot; return Snapshot;
}); });
case ConfigureWorkflow configureWorkflow: case AddWorkflow addWorkflow:
return UpdateReturn(configureWorkflow, c => return UpdateReturn(addWorkflow, c =>
{ {
GuardAppWorkflows.CanConfigure(c); GuardAppWorkflows.CanAdd(c);
ConfigureWorkflow(c); AddWorkflow(c);
return Snapshot;
});
case UpdateWorkflow updateWorkflow:
return UpdateReturn(updateWorkflow, c =>
{
GuardAppWorkflows.CanUpdate(Snapshot.Workflows, c);
UpdateWorkflow(c);
return Snapshot;
});
case DeleteWorkflow deleteWorkflow:
return UpdateReturn(deleteWorkflow, c =>
{
GuardAppWorkflows.CanDelete(Snapshot.Workflows, c);
DeleteWorkflow(c);
return Snapshot; return Snapshot;
}); });
@ -329,9 +349,19 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
} }
public void ConfigureWorkflow(ConfigureWorkflow command) public void AddWorkflow(AddWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowAdded()));
}
public void UpdateWorkflow(UpdateWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowUpdated()));
}
public void DeleteWorkflow(DeleteWorkflow command)
{ {
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured())); RaiseEvent(SimpleMapper.Map(command, new AppWorkflowDeleted()));
} }
public void AddLanguage(AddLanguage command) public void AddLanguage(AddLanguage command)

23
src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public sealed class AddWorkflow : AppCommand
{
public Guid WorkflowId { get; set; }
public string Name { get; set; }
public AddWorkflow()
{
WorkflowId = Guid.NewGuid();
}
}
}

8
src/Squidex.Web/IGenerateEtag.cs → src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs

@ -7,12 +7,10 @@
using System; using System;
namespace Squidex.Web namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public interface IGenerateETag public sealed class DeleteWorkflow : AppCommand
{ {
Guid Id { get; } public Guid WorkflowId { get; set; }
long Version { get; }
} }
} }

5
src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs → src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs

@ -5,12 +5,15 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class ConfigureWorkflow : AppCommand public sealed class UpdateWorkflow : AppCommand
{ {
public Guid WorkflowId { get; set; }
public Workflow Workflow { get; set; } public Workflow Workflow { get; set; }
} }
} }

37
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -13,11 +14,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{ {
public static class GuardAppWorkflows public static class GuardAppWorkflows
{ {
public static void CanConfigure(ConfigureWorkflow command) public static void CanAdd(AddWorkflow command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot configure workflow.", e => Validate.It(() => "Cannot add workflow.", e =>
{
if (string.IsNullOrWhiteSpace(command.Name))
{
e(Not.Defined("Name"), nameof(command.Name));
}
});
}
public static void CanUpdate(Workflows workflows, UpdateWorkflow command)
{
Guard.NotNull(command, nameof(command));
GetWorkflowOrThrow(workflows, command.WorkflowId);
Validate.It(() => "Cannot update workflow.", e =>
{ {
if (command.Workflow == null) if (command.Workflow == null)
{ {
@ -72,5 +88,22 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
} }
}); });
} }
public static void CanDelete(Workflows workflows, DeleteWorkflow command)
{
Guard.NotNull(command, nameof(command));
GetWorkflowOrThrow(workflows, command.WorkflowId);
}
private static Workflow GetWorkflowOrThrow(Workflows workflows, Guid id)
{
if (!workflows.TryGetValue(id, out var workflow))
{
throw new DomainObjectNotFoundException(id.ToString(), "Workflows", typeof(IAppEntity));
}
return workflow;
}
} }
} }

14
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -96,9 +96,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
Clients = Clients.Revoke(@event.Id); Clients = Clients.Revoke(@event.Id);
} }
protected void On(AppWorkflowConfigured @event) protected void On(AppWorkflowAdded @event)
{ {
Workflows = Workflows.Set(@event.Workflow); Workflows = Workflows.Add(@event.WorkflowId, @event.Name);
}
protected void On(AppWorkflowUpdated @event)
{
Workflows = Workflows.Update(@event.WorkflowId, @event.Workflow);
}
protected void On(AppWorkflowDeleted @event)
{
Workflows = Workflows.Remove(@event.WorkflowId);
} }
protected void On(AppPatternAdded @event) protected void On(AppPatternAdded @event)

2
src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs

@ -82,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
private async Task RestoreTagsAsync(Guid appId, BackupReader reader) private async Task RestoreTagsAsync(Guid appId, BackupReader reader)
{ {
var tags = await reader.ReadJsonAttachmentAsync<TagSet>(TagsFile); var tags = await reader.ReadJsonAttachmentAsync<TagsExport>(TagsFile);
await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags); await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags);
} }

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

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

14
src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs

@ -0,0 +1,14 @@
// ==========================================================================
// 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 abstract class ContentUpdateCommand : ContentDataCommand
{
public bool AsDraft { get; set; }
}
}

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

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Contents.Commands namespace Squidex.Domain.Apps.Entities.Contents.Commands
{ {
public sealed class PatchContent : ContentDataCommand public sealed class PatchContent : ContentUpdateCommand
{ {
} }
} }

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

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Contents.Commands namespace Squidex.Domain.Apps.Entities.Contents.Commands
{ {
public sealed class UpdateContent : ContentDataCommand public sealed class UpdateContent : ContentUpdateCommand
{ {
} }
} }

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

@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, Guid.Empty, () => "Failed to create content."); var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, Guid.Empty, () => "Failed to create content.");
GuardContent.CanCreate(ctx.Schema, c); await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c);
await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data); await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data);
await ctx.EnrichAsync(c.Data); await ctx.EnrichAsync(c.Data);
@ -93,17 +93,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
case UpdateContent updateContent: case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, async c => return UpdateReturnAsync(updateContent, async c =>
{ {
await GuardContent.CanUpdate(Snapshot, contentWorkflow, c); var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
return await UpdateAsync(c, x => c.Data, false); await GuardContent.CanUpdate(Snapshot, contentWorkflow, c, isProposal);
return await UpdateAsync(c, x => c.Data, false, isProposal);
}); });
case PatchContent patchContent: case PatchContent patchContent:
return UpdateReturnAsync(patchContent, async c => return UpdateReturnAsync(patchContent, async c =>
{ {
await GuardContent.CanPatch(Snapshot, contentWorkflow, c); var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
await GuardContent.CanPatch(Snapshot, contentWorkflow, c, isProposal);
return await UpdateAsync(c, c.Data.MergeInto, true); return await UpdateAsync(c, c.Data.MergeInto, true, isProposal);
}); });
case ChangeContentStatus changeContentStatus: case ChangeContentStatus changeContentStatus:
@ -111,9 +115,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
try try
{ {
var isChangeConfirm = Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published;
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content."); var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content.");
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c); await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c, isChangeConfirm);
if (c.DueTime.HasValue) if (c.DueTime.HasValue)
{ {
@ -121,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
else else
{ {
if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published) if (isChangeConfirm)
{ {
ConfirmChanges(c); ConfirmChanges(c);
} }
@ -190,10 +196,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
private async Task<object> UpdateAsync(ContentDataCommand c, Func<NamedContentData, NamedContentData> newDataFunc, bool partial) private async Task<object> UpdateAsync(ContentUpdateCommand command, Func<NamedContentData, NamedContentData> newDataFunc, bool partial, bool isProposal)
{ {
var isProposal = c.AsDraft && Snapshot.Status == Status.Published;
var currentData = var currentData =
isProposal ? isProposal ?
Snapshot.DataDraft : Snapshot.DataDraft :
@ -207,22 +211,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (partial) if (partial)
{ {
await ctx.ValidatePartialAsync(c.Data); await ctx.ValidatePartialAsync(command.Data);
} }
else else
{ {
await ctx.ValidateAsync(c.Data); await ctx.ValidateAsync(command.Data);
} }
newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", c, newData, Snapshot.Data); newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", command, newData, Snapshot.Data);
if (isProposal) if (isProposal)
{ {
ProposeUpdate(c, newData); ProposeUpdate(command, newData);
} }
else else
{ {
Update(c, newData); Update(command, newData);
} }
} }

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

@ -8,7 +8,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OData; using Microsoft.OData;
@ -23,9 +22,7 @@ using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Queries.OData; using Squidex.Infrastructure.Queries.OData;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Security;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Identity;
#pragma warning disable RECS0147 #pragma warning disable RECS0147

6
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -12,6 +12,7 @@ using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result); return Task.FromResult(result);
} }
public Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
{
return TaskHelper.True;
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{ {
var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next); var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);

57
src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs

@ -0,0 +1,57 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultWorkflowsValidator : IWorkflowsValidator
{
private readonly IAppProvider appProvider;
public DefaultWorkflowsValidator(IAppProvider appProvider)
{
Guard.NotNull(appProvider, nameof(appProvider));
this.appProvider = appProvider;
}
public async Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows)
{
Guard.NotNull(workflows, nameof(workflows));
var errors = new List<string>();
if (workflows.Values.Count(x => x.SchemaIds.Count == 0) > 1)
{
errors.Add("Multiple workflows cover all schemas.");
}
var uniqueSchemaIds = workflows.Values.SelectMany(x => x.SchemaIds).Distinct().ToList();
foreach (var schemaId in uniqueSchemaIds)
{
if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1)
{
var schema = await appProvider.GetSchemaAsync(appId, schemaId);
if (schema != null)
{
errors.Add($"The schema `{schema.SchemaDef.Name}` is covered by multiple workflows.");
}
}
}
return errors;
}
}
}

48
src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -34,21 +34,28 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema) public async Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id); var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id);
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray();
} }
public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user); return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content.DataDraft, user);
}
public async Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user)
{
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id);
return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && CanUse(transition, data, user);
} }
public async Task<bool> CanUpdateAsync(IContentEntity content) public async Task<bool> CanUpdateAsync(IContentEntity content)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
if (workflow.TryGetStep(content.Status, out var step)) if (workflow.TryGetStep(content.Status, out var step))
{ {
@ -60,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo> GetInfoAsync(IContentEntity content) public async Task<StatusInfo> GetInfoAsync(IContentEntity content)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
if (workflow.TryGetStep(content.Status, out var step)) if (workflow.TryGetStep(content.Status, out var step))
{ {
@ -72,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema) public async Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id); var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id);
var (status, step) = workflow.GetInitialStep(); var (status, step) = workflow.GetInitialStep();
@ -83,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var result = new List<StatusInfo>(); var result = new List<StatusInfo>();
var workflow = await GetWorkflowAsync(content.AppId.Id); var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id);
foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) foreach (var (to, step, transition) in workflow.GetTransitions(content.Status))
{ {
if (CanUse(transition, content, user)) if (CanUse(transition, content.DataDraft, user))
{ {
result.Add(new StatusInfo(to, GetColor(step))); result.Add(new StatusInfo(to, GetColor(step)));
} }
@ -96,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result.ToArray(); return result.ToArray();
} }
private bool CanUse(WorkflowTransition transition, IContentEntity content, ClaimsPrincipal user) private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user)
{ {
if (!string.IsNullOrWhiteSpace(transition.Role)) if (!string.IsNullOrWhiteSpace(transition.Role))
{ {
@ -108,17 +115,34 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (!string.IsNullOrWhiteSpace(transition.Expression)) if (!string.IsNullOrWhiteSpace(transition.Expression))
{ {
return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression); return scriptEngine.Evaluate("data", data, transition.Expression);
} }
return true; return true;
} }
private async Task<Workflow> GetWorkflowAsync(Guid appId) private async Task<Workflow> GetWorkflowAsync(Guid appId, Guid schemaId)
{ {
Workflow result = null;
var app = await appProvider.GetAppAsync(appId); var app = await appProvider.GetAppAsync(appId);
return app?.Workflows.GetFirst(); if (app != null)
{
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId));
if (result == null)
{
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0);
}
}
if (result == null)
{
result = Workflow.Default;
}
return result;
} }
private static string GetColor(WorkflowStep step) private static string GetColor(WorkflowStep step)

2
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q)));
return (result.Any(x => x.HasError), result.ToArray(x => x.Response)); return (result.Any(x => x.HasError), result.Map(x => x.Response));
} }
public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query) public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query)

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

@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
public static class GuardContent public static class GuardContent
{ {
public static void CanCreate(ISchemaEntity schema, CreateContent command) public static async Task CanCreate(ISchemaEntity schema, IContentWorkflow contentWorkflow, CreateContent command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -29,9 +29,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
throw new DomainException("Singleton content cannot be created."); throw new DomainException("Singleton content cannot be created.");
} }
if (command.Publish && !await contentWorkflow.CanPublishOnCreateAsync(schema, command.Data, command.User))
{
throw new DomainException("Content workflow prevents publishing.");
}
} }
public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command) public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command, bool isProposal)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -40,10 +45,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
ValidateData(command, e); ValidateData(command, e);
}); });
await ValidateCanUpdate(content, contentWorkflow); if (!isProposal)
{
await ValidateCanUpdate(content, contentWorkflow);
}
} }
public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command) public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command, bool isProposal)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -52,7 +60,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
ValidateData(command, e); ValidateData(command, e);
}); });
await ValidateCanUpdate(content, contentWorkflow); if (!isProposal)
{
await ValidateCanUpdate(content, contentWorkflow);
}
} }
public static void CanDiscardChanges(bool isPending, DiscardChanges command) public static void CanDiscardChanges(bool isPending, DiscardChanges command)
@ -65,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
} }
} }
public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command) public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command, bool isChangeConfirm)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -76,20 +87,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
return Validate.It(() => "Cannot change status.", async e => return Validate.It(() => "Cannot change status.", async e =>
{ {
if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User)) if (isChangeConfirm)
{ {
if (content.Status == command.Status && content.Status == Status.Published) if (!content.IsPending)
{
if (!content.IsPending)
{
e("Content has no changes to publish.", nameof(command.Status));
}
}
else
{ {
e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status)); e("Content has no changes to publish.", nameof(command.Status));
} }
} }
else if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User))
{
e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status));
}
if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant()) if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant())
{ {

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

@ -16,6 +16,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema); Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content); Task<bool> CanUpdateAsync(IContentEntity content);

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

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IWorkflowsValidator
{
Task<IReadOnlyList<string>> ValidateAsync(Guid appId, Workflows workflows);
}
}

6
src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs

@ -45,17 +45,17 @@ namespace Squidex.Domain.Apps.Entities.Tags
return GetGrain(appId, group).DenormalizeTagsAsync(ids); return GetGrain(appId, group).DenormalizeTagsAsync(ids);
} }
public Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string group) public Task<TagsSet> GetTagsAsync(Guid appId, string group)
{ {
return GetGrain(appId, group).GetTagsAsync(); return GetGrain(appId, group).GetTagsAsync();
} }
public Task<TagSet> GetExportableTagsAsync(Guid appId, string group) public Task<TagsExport> GetExportableTagsAsync(Guid appId, string group)
{ {
return GetGrain(appId, group).GetExportableTagsAsync(); return GetGrain(appId, group).GetExportableTagsAsync();
} }
public Task RebuildTagsAsync(Guid appId, string group, TagSet tags) public Task RebuildTagsAsync(Guid appId, string group, TagsExport tags)
{ {
return GetGrain(appId, group).RebuildAsync(tags); return GetGrain(appId, group).RebuildAsync(tags);
} }

6
src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs

@ -20,12 +20,12 @@ namespace Squidex.Domain.Apps.Entities.Tags
Task<Dictionary<string, string>> DenormalizeTagsAsync(HashSet<string> ids); Task<Dictionary<string, string>> DenormalizeTagsAsync(HashSet<string> ids);
Task<Dictionary<string, int>> GetTagsAsync(); Task<TagsSet> GetTagsAsync();
Task<TagSet> GetExportableTagsAsync(); Task<TagsExport> GetExportableTagsAsync();
Task ClearAsync(); Task ClearAsync();
Task RebuildAsync(TagSet tags); Task RebuildAsync(TagsExport tags);
} }
} }

12
src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Tags
[CollectionName("Index_Tags")] [CollectionName("Index_Tags")]
public sealed class GrainState public sealed class GrainState
{ {
public TagSet Tags { get; set; } = new TagSet(); public TagsExport Tags { get; set; } = new TagsExport();
} }
public TagGrain(IStore<string> store) public TagGrain(IStore<string> store)
@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Tags
return ClearStateAsync(); return ClearStateAsync();
} }
public Task RebuildAsync(TagSet tags) public Task RebuildAsync(TagsExport tags)
{ {
State.Tags = tags; State.Tags = tags;
@ -132,12 +132,14 @@ namespace Squidex.Domain.Apps.Entities.Tags
return Task.FromResult(result); return Task.FromResult(result);
} }
public Task<Dictionary<string, int>> GetTagsAsync() public Task<TagsSet> GetTagsAsync()
{ {
return Task.FromResult(State.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); var tags = State.Tags.Values.ToDictionary(x => x.Name, x => x.Count);
return Task.FromResult(new TagsSet(tags, Persistence.Version));
} }
public Task<TagSet> GetExportableTagsAsync() public Task<TagsExport> GetExportableTagsAsync()
{ {
return Task.FromResult(State.Tags); return Task.FromResult(State.Tags);
} }

20
src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppWorkflowAdded))]
public sealed class AppWorkflowAdded : AppEvent
{
public Guid WorkflowId { get; set; }
public string Name { get; set; }
}
}

18
src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppWorkflowDeleted))]
public sealed class AppWorkflowDeleted : AppEvent
{
public Guid WorkflowId { get; set; }
}
}

7
src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs → src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs

@ -5,14 +5,17 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps namespace Squidex.Domain.Apps.Events.Apps
{ {
[EventType(nameof(AppWorkflowConfigured))] [EventType(nameof(AppWorkflowUpdated))]
public sealed class AppWorkflowConfigured : AppEvent public sealed class AppWorkflowUpdated : AppEvent
{ {
public Guid WorkflowId { get; set; }
public Workflow Workflow { get; set; } public Workflow Workflow { get; set; }
} }
} }

16
src/Squidex.Infrastructure/CollectionExtensions.cs

@ -53,7 +53,7 @@ namespace Squidex.Infrastructure
return source.Concat(Enumerable.Repeat(value, 1)); return source.Concat(Enumerable.Repeat(value, 1));
} }
public static TResult[] ToArray<TResult, T>(this T[] value, Func<T, TResult> convert) public static TResult[] Map<TResult, T>(this T[] value, Func<T, TResult> convert)
{ {
var result = new TResult[value.Length]; var result = new TResult[value.Length];
@ -65,20 +65,6 @@ namespace Squidex.Infrastructure
return result; return result;
} }
public static TResult[] ToArray<TResult, T>(this IReadOnlyCollection<T> value, Func<T, TResult> convert)
{
var result = new TResult[value.Count];
var i = 0;
foreach (var v in value)
{
result[i] = convert(v);
i++;
}
return result;
}
public static int SequentialHashCode<T>(this IEnumerable<T> collection) public static int SequentialHashCode<T>(this IEnumerable<T> collection)
{ {
return collection.SequentialHashCode(EqualityComparer<T>.Default); return collection.SequentialHashCode(EqualityComparer<T>.Default);

12
src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs

@ -36,6 +36,18 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
} }
} }
protected override JsonArrayContract CreateArrayContract(Type objectType)
{
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>))
{
var implementationType = typeof(List<>).MakeGenericType(objectType.GetGenericArguments());
return base.CreateArrayContract(implementationType);
}
return base.CreateArrayContract(objectType);
}
protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) protected override JsonDictionaryContract CreateDictionaryContract(Type objectType)
{ {
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))

2
src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs

@ -190,7 +190,7 @@ namespace Squidex.Infrastructure.States
private EventData[] GetEventData(Envelope<IEvent>[] events, Guid commitId) private EventData[] GetEventData(Envelope<IEvent>[] events, Guid commitId)
{ {
return events.ToArray(x => eventDataFormatter.ToEventData(x, commitId, true)); return events.Map(x => eventDataFormatter.ToEventData(x, commitId, true));
} }
private string GetStreamName() private string GetStreamName()

4
src/Squidex.Shared/Permissions.cs

@ -121,8 +121,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 AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.discard";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; public const string AppContentsDraftPublish = "squidex.apps.{app}.contents.{name}.draft.publish";
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";

4
src/Squidex.Web/ApiExceptionFilterAttribute.cs

@ -93,7 +93,7 @@ namespace Squidex.Web
private static string[] ToDetails(ValidationException ex) private static string[] ToDetails(ValidationException ex)
{ {
return ex.Errors?.ToArray(e => return ex.Errors?.Select(e =>
{ {
if (e.PropertyNames?.Any() == true) if (e.PropertyNames?.Any() == true)
{ {
@ -103,7 +103,7 @@ namespace Squidex.Web
{ {
return e.Message; return e.Message;
} }
}); }).ToArray();
} }
} }
} }

1
src/Squidex.Web/ApiPermissionAttribute.cs

@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Identity;
namespace Squidex.Web namespace Squidex.Web
{ {

42
src/Squidex.Web/Deferred.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Web
{
public struct Deferred
{
private readonly Lazy<Task<object>> value;
public Task<object> Value
{
get { return value.Value; }
}
private Deferred(Func<Task<object>> value)
{
this.value = new Lazy<Task<object>>(value);
}
public static Deferred Response(Func<object> factory)
{
Guard.NotNull(factory, nameof(factory));
return new Deferred(() => Task.FromResult(factory()));
}
public static Deferred AsyncResponse<T>(Func<Task<T>> factory)
{
Guard.NotNull(factory, nameof(factory));
return new Deferred(async () => await factory());
}
}
}

61
src/Squidex.Web/ETagExtensions.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
@ -17,40 +18,54 @@ namespace Squidex.Web
{ {
private static readonly int GuidLength = Guid.Empty.ToString().Length; private static readonly int GuidLength = Guid.Empty.ToString().Length;
public static string ToManyEtag<T>(this IReadOnlyList<T> items, long total = 0) where T : IGenerateETag public static string ToEtag<T>(this IReadOnlyList<T> items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion
{ {
using (Profiler.Trace("CalculateEtag")) using (Profiler.Trace("CalculateEtag"))
{ {
var unhashed = Unhashed(items, total); var unhashed = Unhashed(items, 0, app);
return unhashed.Sha256Base64(); return unhashed.Sha256Base64();
} }
} }
private static string Unhashed<T>(IReadOnlyList<T> items, long total) where T : IGenerateETag public static string ToEtag<T>(this IResultList<T> items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion
{ {
var sb = new StringBuilder((items.Count * (GuidLength + 4)) + 10); using (Profiler.Trace("CalculateEtag"))
{
var unhashed = Unhashed(items, items.Total, app);
return unhashed.Sha256Base64();
}
}
private static string Unhashed<T>(IReadOnlyList<T> items, long total, IEntityWithVersion app) where T : IEntity, IEntityWithVersion
{
var sb = new StringBuilder((items.Count * (GuidLength + 8)) + 10);
for (var i = 0; i < items.Count; i++)
{
sb.Append(";");
sb.Append(items[i].ToEtag());
}
sb.Append(total);
sb.Append("_"); sb.Append("_");
sb.Append(total);
if (items.Count > 0) if (app != null)
{ {
sb.Append(items[0].Id.ToString()); sb.Append("_");
sb.Append(items[0].Version); sb.Append(app.Version);
for (var i = 1; i < items.Count; i++)
{
sb.Append(";");
sb.Append(items[i].Id.ToString());
sb.Append(items[i].Version);
}
} }
return sb.ToString().Sha256Base64(); return sb.ToString();
}
public static string ToSurrogateKey<T>(this T item) where T : IEntity
{
return item.Id.ToString();
} }
public static string ToSurrogateKeys<T>(this IReadOnlyList<T> items) where T : IGenerateETag public static string ToSurrogateKeys<T>(this IReadOnlyList<T> items) where T : IEntity
{ {
if (items.Count == 0) if (items.Count == 0)
{ {
@ -70,9 +85,17 @@ namespace Squidex.Web
return sb.ToString(); return sb.ToString();
} }
public static string ToEtag<T>(this T item) where T : IGenerateETag public static string ToEtag<T>(this T item, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion
{ {
return item.Version.ToString(); var result = $"{item.Id};{item.Version}";
if (app != null)
{
result += ";";
result += app.Version;
}
return result;
} }
} }
} }

27
src/Squidex.Web/MyJsonInheritanceConverter.cs → src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs

@ -16,17 +16,16 @@ using Squidex.Infrastructure;
#pragma warning disable RECS0108 // Warns about static fields in generic types #pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Web namespace Squidex.Web.Json
{ {
public class MyJsonInheritanceConverter<T> : JsonInheritanceConverter public class TypedJsonInheritanceConverter<T> : JsonInheritanceConverter
{ {
private static readonly Dictionary<string, Type> DefaultMapping = new Dictionary<string, Type>(); private static readonly Lazy<Dictionary<string, Type>> DefaultMapping = new Lazy<Dictionary<string, Type>>(() =>
private readonly IReadOnlyDictionary<string, Type> maping;
static MyJsonInheritanceConverter()
{ {
var baseName = typeof(T).Name; var baseName = typeof(T).Name;
var result = new Dictionary<string, Type>();
void AddType(Type type) void AddType(Type type)
{ {
var discriminator = type.Name; var discriminator = type.Name;
@ -36,7 +35,7 @@ namespace Squidex.Web
discriminator = discriminator.Substring(0, discriminator.Length - baseName.Length); discriminator = discriminator.Substring(0, discriminator.Length - baseName.Length);
} }
DefaultMapping[discriminator] = type; result[discriminator] = type;
} }
foreach (var attribute in typeof(T).GetCustomAttributes<KnownTypeAttribute>()) foreach (var attribute in typeof(T).GetCustomAttributes<KnownTypeAttribute>())
@ -66,17 +65,21 @@ namespace Squidex.Web
} }
} }
} }
}
public MyJsonInheritanceConverter(string discriminator) return result;
: this(discriminator, DefaultMapping) });
private readonly IReadOnlyDictionary<string, Type> maping;
public TypedJsonInheritanceConverter(string discriminator)
: this(discriminator, DefaultMapping.Value)
{ {
} }
public MyJsonInheritanceConverter(string discriminator, IReadOnlyDictionary<string, Type> mapping) public TypedJsonInheritanceConverter(string discriminator, IReadOnlyDictionary<string, Type> mapping)
: base(typeof(T), discriminator) : base(typeof(T), discriminator)
{ {
maping = mapping ?? DefaultMapping; maping = mapping ?? DefaultMapping.Value;
} }
protected override Type GetDiscriminatorType(JObject jObject, Type objectType, string discriminatorValue) protected override Type GetDiscriminatorType(JObject jObject, Type objectType, string discriminatorValue)

26
src/Squidex.Web/Pipeline/DeferredActionFilter.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Squidex.Web.Pipeline
{
public sealed class DeferredActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var resultContext = await next();
if (resultContext.Result is ObjectResult objectResult && objectResult.Value is Deferred deferred)
{
objectResult.Value = await deferred.Value;
}
}
}
}

2
src/Squidex.Web/ETagFilter.cs → src/Squidex.Web/Pipeline/ETagFilter.cs

@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
namespace Squidex.Web namespace Squidex.Web.Pipeline
{ {
public sealed class ETagFilter : IAsyncActionFilter public sealed class ETagFilter : IAsyncActionFilter
{ {

2
src/Squidex.Web/ETagOptions.cs → src/Squidex.Web/Pipeline/ETagOptions.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.Web namespace Squidex.Web.Pipeline
{ {
public sealed class ETagOptions public sealed class ETagOptions
{ {

1
src/Squidex.Web/ResourceLink.cs

@ -19,7 +19,6 @@ namespace Squidex.Web
[Display(Description = "The link method.")] [Display(Description = "The link method.")]
public string Method { get; set; } public string Method { get; set; }
[Required]
[Display(Description = "Additional data about the link.")] [Display(Description = "Additional data about the link.")]
public string Metadata { get; set; } public string Metadata { get; set; }
} }

7
src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -46,9 +46,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetClients(string app) public IActionResult GetClients(string app)
{ {
var response = ClientsDto.FromApp(App, this); var response = Deferred.Response(() =>
{
return ClientsDto.FromApp(App, this);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }

31
src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -48,9 +48,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetContributors(string app) public IActionResult GetContributors(string app)
{ {
var response = ContributorsDto.FromApp(App, appPlansProvider, this, false); var response = Deferred.Response(() =>
{
return ContributorsDto.FromApp(App, appPlansProvider, this, false);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }
@ -73,18 +76,8 @@ namespace Squidex.Areas.Api.Controllers.Apps
public async Task<IActionResult> PostContributor(string app, [FromBody] AssignContributorDto request) public async Task<IActionResult> PostContributor(string app, [FromBody] AssignContributorDto request)
{ {
var command = request.ToCommand(); var command = request.ToCommand();
var context = await CommandBus.PublishAsync(command);
var response = (ContributorsDto)null; var response = await InvokeCommandAsync(command);
if (context.PlainResult is IAppEntity newApp)
{
response = ContributorsDto.FromApp(newApp, appPlansProvider, this, false);
}
else if (context.PlainResult is InvitedResult invited)
{
response = ContributorsDto.FromApp(invited.App, appPlansProvider, this, true);
}
return CreatedAtAction(nameof(GetContributors), new { app }, response); return CreatedAtAction(nameof(GetContributors), new { app }, response);
} }
@ -117,10 +110,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); if (context.PlainResult is InvitedResult invited)
var response = ContributorsDto.FromApp(result, appPlansProvider, this, false); {
return ContributorsDto.FromApp(invited.App, appPlansProvider, this, true);
return response; }
else
{
return ContributorsDto.FromApp(context.Result<IAppEntity>(), appPlansProvider, this, false);
}
} }
} }
} }

7
src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -45,9 +45,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetLanguages(string app) public IActionResult GetLanguages(string app)
{ {
var response = AppLanguagesDto.FromApp(App, this); var response = Deferred.Response(() =>
{
return AppLanguagesDto.FromApp(App, this);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }

7
src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs

@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetPatterns(string app) public IActionResult GetPatterns(string app)
{ {
var response = PatternsDto.FromApp(App, this); var response = Deferred.Response(() =>
{
return PatternsDto.FromApp(App, this);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }

16
src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetRoles(string app) public IActionResult GetRoles(string app)
{ {
var response = RolesDto.FromApp(App, this); var response = Deferred.Response(() =>
{
return RolesDto.FromApp(App, this);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }
@ -67,11 +70,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ProducesResponseType(typeof(string[]), 200)] [ProducesResponseType(typeof(string[]), 200)]
[ApiPermission(Permissions.AppRolesRead)] [ApiPermission(Permissions.AppRolesRead)]
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> GetPermissions(string app) public IActionResult GetPermissions(string app)
{ {
var response = await permissionsProvider.GetPermissionsAsync(App); var response = Deferred.AsyncResponse(() =>
{
return permissionsProvider.GetPermissionsAsync(App);
});
Response.Headers[HeaderNames.ETag] = string.Join(";", response).Sha256Base64(); Response.Headers[HeaderNames.ETag] = string.Concat(response).Sha256Base64();
return Ok(response); return Ok(response);
} }

89
src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -5,11 +5,14 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -22,9 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))] [ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController public sealed class AppWorkflowsController : ApiController
{ {
public AppWorkflowsController(ICommandBus commandBus) private readonly IWorkflowsValidator workflowsValidator;
public AppWorkflowsController(ICommandBus commandBus, IWorkflowsValidator workflowsValidator)
: base(commandBus) : base(commandBus)
{ {
this.workflowsValidator = workflowsValidator;
} }
/// <summary> /// <summary>
@ -36,35 +42,38 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// 404 => App not found. /// 404 => App not found.
/// </returns> /// </returns>
[HttpGet] [HttpGet]
[Route("apps/{app}/workflow/")] [Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)] [ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsRead)] [ApiPermission(Permissions.AppWorkflowsRead)]
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetWorkflow(string app) public IActionResult GetWorkflows(string app)
{ {
var response = WorkflowResponseDto.FromApp(App, this); var response = Deferred.AsyncResponse(() =>
{
return WorkflowsDto.FromAppAsync(workflowsValidator, App, this);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }
/// <summary> /// <summary>
/// Configure workflow of the app. /// Create a workflow.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param> /// <param name="request">The new workflow.</param>
/// <returns> /// <returns>
/// 200 => Workflow configured. /// 200 => Workflow updated.
/// 400 => Workflow is not valid. /// 400 => Workflow request is not valid.
/// 404 => App not found. /// 404 => Workflow or app not found.
/// </returns> /// </returns>
[HttpPut] [HttpPost]
[Route("apps/{app}/workflow/")] [Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)] [ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)] [ApiPermission(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutWorkflow(string app, [FromBody] UpsertWorkflowDto request) public async Task<IActionResult> PostWorkflow(string app, [FromBody] AddWorkflowDto request)
{ {
var command = request.ToCommand(); var command = request.ToCommand();
@ -73,12 +82,60 @@ namespace Squidex.Areas.Api.Controllers.Apps
return Ok(response); return Ok(response);
} }
private async Task<WorkflowResponseDto> InvokeCommandAsync(ICommand command) /// <summary>
/// Update a workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param>
/// <param name="id">The id of the workflow to update.</param>
/// <returns>
/// 200 => Workflow updated.
/// 400 => Workflow request is not valid.
/// 404 => Workflow or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutWorkflow(string app, Guid id, [FromBody] UpdateWorkflowDto request)
{
var command = request.ToCommand(id);
var response = await InvokeCommandAsync(command);
return Ok(response);
}
/// <summary>
/// Delete a workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the workflow to update.</param>
/// <returns>
/// 200 => Workflow deleted.
/// 404 => Workflow or app not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> DeleteWorkflow(string app, Guid id)
{
var command = new DeleteWorkflow { WorkflowId = id };
var response = await InvokeCommandAsync(command);
return Ok(response);
}
private async Task<WorkflowsDto> InvokeCommandAsync(ICommand command)
{ {
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = WorkflowResponseDto.FromApp(result, this); var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this);
return response; return response;
} }

8
src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
@ -62,9 +63,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
var apps = await appProvider.GetUserApps(userOrClientId, userPermissions); var apps = await appProvider.GetUserApps(userOrClientId, userPermissions);
var response = apps.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)); var response = Deferred.Response(() =>
{
return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray();
});
Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); Response.Headers[HeaderNames.ETag] = apps.ToEtag();
return Ok(response); return Ok(response);
} }

19
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs → src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs

@ -6,27 +6,22 @@
// ========================================================================== // ==========================================================================
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Web; using Squidex.Infrastructure.Commands;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class WorkflowResponseDto : Resource public sealed class AddWorkflowDto
{ {
/// <summary> /// <summary>
/// The workflow. /// The name of the workflow.
/// </summary> /// </summary>
[Required] [Required]
public WorkflowDto Workflow { get; set; } public string Name { get; set; }
public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller) public ICommand ToCommand()
{ {
var result = new WorkflowResponseDto return new AddWorkflow { Name = Name };
{
Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name)
};
return result;
} }
} }
} }

4
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -25,7 +25,7 @@ using AllPermissions = Squidex.Shared.Permissions;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class AppDto : Resource, IGenerateETag public sealed class AppDto : Resource
{ {
/// <summary> /// <summary>
/// The name of the app. /// The name of the app.
@ -179,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions)) if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions))
{ {
AddGetLink("workflows", controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflow), values)); AddGetLink("workflows", controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflows), values));
} }
if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions))

5
src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services;
@ -36,11 +37,9 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited) public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited)
{ {
var contributors = app.Contributors.ToArray(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name));
var result = new ContributorsDto var result = new ContributorsDto
{ {
Items = contributors, Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(),
}; };
if (isInvited) if (isInvited)

21
src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs → src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
@ -13,22 +14,33 @@ using Squidex.Domain.Apps.Entities.Apps.Commands;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class UpsertWorkflowDto public sealed class UpdateWorkflowDto
{ {
/// <summary>
/// The name of the workflow.
/// </summary>
public string Name { get; set; }
/// <summary> /// <summary>
/// The workflow steps. /// The workflow steps.
/// </summary> /// </summary>
[Required] [Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public List<Guid> SchemaIds { get; set; }
/// <summary> /// <summary>
/// The initial step. /// The initial step.
/// </summary> /// </summary>
public Status Initial { get; set; } public Status Initial { get; set; }
public ConfigureWorkflow ToCommand() public UpdateWorkflow ToCommand(Guid id)
{ {
var workflow = new Workflow( var workflow = new Workflow(
Initial,
Steps?.ToDictionary( Steps?.ToDictionary(
x => x.Key, x => x.Key,
x => new WorkflowStep( x => new WorkflowStep(
@ -37,9 +49,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), y => new WorkflowTransition(y.Value.Expression, y.Value.Role)),
x.Value.Color, x.Value.Color,
x.Value.NoUpdate)), x.Value.NoUpdate)),
Initial); SchemaIds,
Name);
return new ConfigureWorkflow { Workflow = workflow }; return new UpdateWorkflow { WorkflowId = id, Workflow = workflow };
} }
} }
} }

53
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
@ -17,44 +18,62 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class WorkflowDto : Resource public sealed class WorkflowDto : Resource
{ {
/// <summary>
/// The workflow id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The name of the workflow.
/// </summary>
public string Name { get; set; }
/// <summary> /// <summary>
/// The workflow steps. /// The workflow steps.
/// </summary> /// </summary>
[Required] [Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public IReadOnlyList<Guid> SchemaIds { get; set; }
/// <summary> /// <summary>
/// The initial step. /// The initial step.
/// </summary> /// </summary>
public Status Initial { get; set; } public Status Initial { get; set; }
public static WorkflowDto FromWorkflow(Workflow workflow, ApiController controller, string app) public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app)
{ {
var result = new WorkflowDto var result = SimpleMapper.Map(workflow, new WorkflowDto { Id = id });
{
Steps = workflow.Steps.ToDictionary( result.Steps = workflow.Steps.ToDictionary(
x => x.Key, x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto x => SimpleMapper.Map(x.Value, new WorkflowStepDto
{ {
Transitions = x.Value.Transitions.ToDictionary( Transitions = x.Value.Transitions.ToDictionary(
y => y.Key, y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
})), }));
Initial = workflow.Initial
}; return result.CreateLinks(controller, app, id);
return result.CreateLinks(controller, app);
} }
private WorkflowDto CreateLinks(ApiController controller, string app) private WorkflowDto CreateLinks(ApiController controller, string app, Guid id)
{ {
var values = new { app }; var values = new { app, id };
if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app))
{ {
AddPutLink("update", controller.Url<AppWorkflowsController>(x => nameof(x.PutWorkflow), values)); AddPutLink("update", controller.Url<AppWorkflowsController>(x => nameof(x.PutWorkflow), values));
} }
if (controller.HasPermission(Permissions.AppWorkflowsDelete, app))
{
AddDeleteLink("delete", controller.Url<AppWorkflowsController>(x => nameof(x.DeleteWorkflow), values));
}
return this; return this;
} }
} }

60
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs

@ -0,0 +1,60 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowsDto : Resource
{
/// <summary>
/// The workflow.
/// </summary>
[Required]
public WorkflowDto[] Items { get; set; }
/// <summary>
/// The errros that should be fixed.
/// </summary>
[Required]
public string[] Errors { get; set; }
public static async Task<WorkflowsDto> FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller)
{
var result = new WorkflowsDto
{
Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(),
};
var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows);
result.Errors = errors.ToArray();
return result.CreateLinks(controller, app.Name);
}
private WorkflowsDto CreateLinks(ApiController controller, string app)
{
var values = new { app };
AddSelfLink(controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflows), values));
if (controller.HasPermission(Permissions.AppWorkflowsCreate, app))
{
AddPostLink("create", controller.Url<AppWorkflowsController>(x => nameof(x.PostWorkflow), values));
}
return this;
}
}
}

43
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -77,9 +77,11 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> GetTags(string app) public async Task<IActionResult> GetTags(string app)
{ {
var response = await tagService.GetTagsAsync(AppId, TagGroups.Assets); var tags = await tagService.GetTagsAsync(AppId, TagGroups.Assets);
return Ok(response); Response.Headers[HeaderNames.ETag] = tags.Version.ToString();
return Ok(tags);
} }
/// <summary> /// <summary>
@ -103,14 +105,17 @@ namespace Squidex.Areas.Api.Controllers.Assets
{ {
var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var response = AssetsDto.FromAssets(assets, this, app); var response = Deferred.Response(() =>
{
return AssetsDto.FromAssets(assets, this, app);
});
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys && assets.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{ {
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); Response.Headers["Surrogate-Key"] = assets.ToSurrogateKeys();
} }
Response.Headers[HeaderNames.ETag] = response.ToEtag(); Response.Headers[HeaderNames.ETag] = assets.ToEtag();
return Ok(response); return Ok(response);
} }
@ -138,14 +143,17 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NotFound(); return NotFound();
} }
var response = AssetDto.FromAsset(asset, this, app); var response = Deferred.Response(() =>
{
return AssetDto.FromAsset(asset, this, app);
});
if (controllerOptions.Value.EnableSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
Response.Headers["Surrogate-Key"] = asset.Id.ToString(); Response.Headers["Surrogate-Key"] = asset.ToSurrogateKey();
} }
Response.Headers[HeaderNames.ETag] = asset.Version.ToString(); Response.Headers[HeaderNames.ETag] = asset.ToEtag();
return Ok(response); return Ok(response);
} }
@ -175,10 +183,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var command = new CreateAsset { File = assetFile }; var command = new CreateAsset { File = assetFile };
var context = await CommandBus.PublishAsync(command); var response = await InvokeCommandAsync(app, command);
var result = context.Result<AssetCreatedResult>();
var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate);
return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response);
} }
@ -263,10 +268,14 @@ namespace Squidex.Areas.Api.Controllers.Assets
{ {
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IEnrichedAssetEntity>(); if (context.PlainResult is AssetCreatedResult created)
var response = AssetDto.FromAsset(result, this, app); {
return AssetDto.FromAsset(created.Asset, this, app, created.IsDuplicate);
return response; }
else
{
return AssetDto.FromAsset(context.Result<IEnrichedAssetEntity>(), this, app);
}
} }
private async Task<AssetFile> CheckAssetFileAsync(IReadOnlyList<IFormFile> file) private async Task<AssetFile> CheckAssetFileAsync(IReadOnlyList<IFormFile> file)

2
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs

@ -18,7 +18,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Assets.Models namespace Squidex.Areas.Api.Controllers.Assets.Models
{ {
public sealed class AssetDto : Resource, IGenerateETag public sealed class AssetDto : Resource
{ {
/// <summary> /// <summary>
/// The id of the asset. /// The id of the asset.

10
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs

@ -27,16 +27,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required] [Required]
public AssetDto[] Items { get; set; } public AssetDto[] Items { get; set; }
public string ToEtag()
{
return Items.ToManyEtag(Total);
}
public string ToSurrogateKeys()
{
return Items.ToSurrogateKeys();
}
public static AssetsDto FromAssets(IResultList<IEnrichedAssetEntity> assets, ApiController controller, string app) public static AssetsDto FromAssets(IResultList<IEnrichedAssetEntity> assets, ApiController controller, string app)
{ {
var response = new AssetsDto var response = new AssetsDto

2
src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs

@ -62,6 +62,8 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models
AddDeleteLink("delete", controller.Url<BackupsController>(x => nameof(x.DeleteBackup), values)); AddDeleteLink("delete", controller.Url<BackupsController>(x => nameof(x.DeleteBackup), values));
} }
AddGetLink("download", controller.Url<BackupContentController>(x => nameof(x.GetBackupContent), values));
return this; return this;
} }
} }

2
src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs → src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs

@ -10,7 +10,7 @@ using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Backups.Models namespace Squidex.Areas.Api.Controllers.Backups.Models
{ {
public sealed class RestoreRequest public sealed class RestoreRequestDto
{ {
/// <summary> /// <summary>
/// The name of the app. /// The name of the app.

2
src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs

@ -68,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[HttpPost] [HttpPost]
[Route("apps/restore/")] [Route("apps/restore/")]
[ApiPermission(Permissions.AdminRestore)] [ApiPermission(Permissions.AdminRestore)]
public async Task<IActionResult> PostRestore([FromBody] RestoreRequest request) public async Task<IActionResult> PostRestore([FromBody] RestoreRequestDto request)
{ {
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id); var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);

8
src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -55,9 +55,13 @@ namespace Squidex.Areas.Api.Controllers.Comments
public async Task<IActionResult> GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any) public async Task<IActionResult> GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any)
{ {
var result = await grainFactory.GetGrain<ICommentGrain>(commentsId).GetCommentsAsync(version); var result = await grainFactory.GetGrain<ICommentGrain>(commentsId).GetCommentsAsync(version);
var response = CommentsDto.FromResult(result);
Response.Headers[HeaderNames.ETag] = response.Version.ToString(); var response = Deferred.Response(() =>
{
return CommentsDto.FromResult(result);
});
Response.Headers[HeaderNames.ETag] = result.Version.ToString();
return Ok(response); return Ok(response);
} }

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

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -126,14 +127,17 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids); var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow); var response = Deferred.AsyncResponse(() =>
{
return ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow);
});
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) if (ShouldProvideSurrogateKeys(contents))
{ {
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys();
} }
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; Response.Headers[HeaderNames.ETag] = contents.ToEtag(App);
return Ok(response); return Ok(response);
} }
@ -160,16 +164,19 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name); var response = Deferred.AsyncResponse(async () =>
{
var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow); return await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow);
});
if (ShouldProvideSurrogateKeys(response)) if (ShouldProvideSurrogateKeys(contents))
{ {
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys();
} }
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; Response.Headers[HeaderNames.ETag] = contents.ToEtag(App);
return Ok(response); return Ok(response);
} }
@ -200,10 +207,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
if (controllerOptions.Value.EnableSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
Response.Headers["Surrogate-Key"] = content.Id.ToString(); Response.Headers["Surrogate-Key"] = content.ToSurrogateKey();
} }
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; Response.Headers[HeaderNames.ETag] = content.ToEtag(App);
return Ok(response); return Ok(response);
} }
@ -235,10 +242,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
if (controllerOptions.Value.EnableSurrogateKeys) if (controllerOptions.Value.EnableSurrogateKeys)
{ {
Response.Headers["Surrogate-Key"] = content.Id.ToString(); Response.Headers["Surrogate-Key"] = content.ToSurrogateKey();
} }
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}"; Response.Headers[HeaderNames.ETag] = content.ToEtag(App);
return Ok(response.Data); return Ok(response.Data);
} }
@ -267,11 +274,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
await contentQuery.GetSchemaOrThrowAsync(Context, name); await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
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 response = await InvokeCommandAsync(app, name, command); var response = await InvokeCommandAsync(app, name, command);
@ -367,11 +369,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
await contentQuery.GetSchemaOrThrowAsync(Context, name); await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
return new ForbidResult();
}
var command = request.ToCommand(id); var command = request.ToCommand(id);
var response = await InvokeCommandAsync(app, name, command); var response = await InvokeCommandAsync(app, name, command);
@ -396,7 +393,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut] [HttpPut]
[Route("content/{app}/{name}/{id}/discard/")] [Route("content/{app}/{name}/{id}/discard/")]
[ProducesResponseType(typeof(ContentsDto), 200)] [ProducesResponseType(typeof(ContentsDto), 200)]
[ApiPermission(Permissions.AppContentsDiscard)] [ApiPermission(Permissions.AppContentsDraftDiscard)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id) public async Task<IActionResult> DiscardDraft(string app, string name, Guid id)
{ {
@ -447,9 +444,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
return response; return response;
} }
private bool ShouldProvideSurrogateKeys(ContentsDto response) private bool ShouldProvideSurrogateKeys(IReadOnlyList<IContentEntity> response)
{ {
return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys; return controllerOptions.Value.EnableSurrogateKeys && response.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys;
} }
} }
} }

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

@ -194,7 +194,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema); operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema);
operation.AddResponse("400", "Content data valid."); operation.AddResponse("400", "Content data valid.");
AddSecurity(operation, Permissions.AppContentsStatus); AddSecurity(operation, Permissions.AppContentsUpdate);
}); });
} }
@ -209,7 +209,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
operation.AddResponse("400", "No pending draft."); operation.AddResponse("400", "No pending draft.");
operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema);
AddSecurity(operation, Permissions.AppContentsDiscard); AddSecurity(operation, Permissions.AppContentsDraftDiscard);
}); });
} }

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

@ -1,23 +0,0 @@
// ==========================================================================
// 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.Name);
return Permissions.ForApp(id, app, schema);
}
}
}

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

@ -19,7 +19,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Contents.Models namespace Squidex.Areas.Api.Controllers.Contents.Models
{ {
public sealed class ContentDto : Resource, IGenerateETag public sealed class ContentDto : Resource
{ {
/// <summary> /// <summary>
/// The if of the content item. /// The if of the content item.
@ -122,12 +122,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
if (IsPending) if (IsPending)
{ {
if (controller.HasPermission(Permissions.AppContentsDiscard, app, schema)) if (controller.HasPermission(Permissions.AppContentsDraftDiscard, app, schema))
{ {
AddPutLink("draft/discard", controller.Url<ContentsController>(x => nameof(x.DiscardDraft), values)); AddPutLink("draft/discard", controller.Url<ContentsController>(x => nameof(x.DiscardDraft), values));
} }
if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) if (controller.HasPermission(Permissions.AppContentsDraftPublish, app, schema))
{ {
AddPutLink("draft/publish", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values)); AddPutLink("draft/publish", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values));
} }
@ -146,24 +146,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
} }
AddPatchLink("patch", controller.Url<ContentsController>(x => nameof(x.PatchContent), values)); AddPatchLink("patch", controller.Url<ContentsController>(x => nameof(x.PatchContent), values));
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
if (content.Nexts != null) if (content.Nexts != null)
{
foreach (var next in content.Nexts)
{ {
if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status))) foreach (var next in content.Nexts)
{ {
AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color); AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
} }
} }
} }
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
return this; return this;
} }
} }

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

@ -37,16 +37,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[Required] [Required]
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public string ToEtag()
{
return Items.ToManyEtag(Total);
}
public string ToSurrogateKeys()
{
return Items.ToSurrogateKeys();
}
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents, public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents,
Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow) Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
{ {
@ -80,10 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
{ {
AddPostLink("create", controller.Url<ContentsController>(x => nameof(x.PostContent), values)); 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");
{
AddPostLink("create/publish", controller.Url<ContentsController>(x => nameof(x.PostContent), values) + "?publish=true");
}
} }
} }

5
src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs

@ -40,7 +40,10 @@ namespace Squidex.Areas.Api.Controllers.Languages
[ApiPermission] [ApiPermission]
public IActionResult GetLanguages() public IActionResult GetLanguages()
{ {
var response = Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray(); var response = Deferred.Response(() =>
{
return Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray();
});
Response.Headers[HeaderNames.ETag] = "1"; Response.Headers[HeaderNames.ETag] = "1";

7
src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -51,9 +51,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
{ {
var hasPortal = appPlansBillingManager.HasPortal; var hasPortal = appPlansBillingManager.HasPortal;
var response = AppPlansDto.FromApp(App, appPlansProvider, hasPortal); var response = Deferred.Response(() =>
{
return AppPlansDto.FromApp(App, appPlansProvider, hasPortal);
});
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
return Ok(response); return Ok(response);
} }

4
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs

@ -8,11 +8,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Web; using Squidex.Web.Json;
namespace Squidex.Areas.Api.Controllers.Rules.Models namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
public sealed class RuleActionConverter : MyJsonInheritanceConverter<RuleAction> public sealed class RuleActionConverter : TypedJsonInheritanceConverter<RuleAction>
{ {
public static IReadOnlyDictionary<string, Type> Mapping { get; set; } public static IReadOnlyDictionary<string, Type> Mapping { get; set; }

2
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs

@ -55,7 +55,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
if (oldName != null) if (oldName != null)
{ {
context.Document.Definitions.Remove(oldName); context.Document.Definitions.Remove(oldName);
context.Document.Definitions.Add(action.Key, derivedSchema); context.Document.Definitions.Add($"{action.Key}RuleActionDto", derivedSchema);
} }
} }

2
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs

@ -19,7 +19,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
public sealed class RuleDto : Resource, IGenerateETag public sealed class RuleDto : Resource
{ {
/// <summary> /// <summary>
/// The id of the rule. /// The id of the rule.

4
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs

@ -10,11 +10,11 @@ using System.Linq;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Web; using Squidex.Web.Json;
namespace Squidex.Areas.Api.Controllers.Rules.Models namespace Squidex.Areas.Api.Controllers.Rules.Models
{ {
[JsonConverter(typeof(MyJsonInheritanceConverter<RuleTriggerDto>), "triggerType")] [JsonConverter(typeof(TypedJsonInheritanceConverter<RuleTriggerDto>), "triggerType")]
[KnownType(nameof(Subtypes))] [KnownType(nameof(Subtypes))]
public abstract class RuleTriggerDto public abstract class RuleTriggerDto
{ {

5
src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs

@ -22,11 +22,6 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
[Required] [Required]
public RuleDto[] Items { get; set; } public RuleDto[] Items { get; set; }
public string GenerateEtag()
{
return Items.ToManyEtag(0);
}
public static RulesDto FromRules(IEnumerable<IRuleEntity> items, ApiController controller, string app) public static RulesDto FromRules(IEnumerable<IRuleEntity> items, ApiController controller, string app)
{ {
var result = new RulesDto var result = new RulesDto

14
src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -58,9 +58,12 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetActions() public IActionResult GetActions()
{ {
var etag = string.Join(";", ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64(); var etag = string.Concat(ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64();
var response = ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value)); var response = Deferred.Response(() =>
{
return ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value));
});
Response.Headers[HeaderNames.ETag] = etag; Response.Headers[HeaderNames.ETag] = etag;
@ -84,9 +87,12 @@ namespace Squidex.Areas.Api.Controllers.Rules
{ {
var rules = await appProvider.GetRulesAsync(AppId); var rules = await appProvider.GetRulesAsync(AppId);
var response = RulesDto.FromRules(rules, this, app); var response = Deferred.Response(() =>
{
return RulesDto.FromRules(rules, this, app);
});
Response.Headers[HeaderNames.ETag] = response.GenerateEtag(); Response.Headers[HeaderNames.ETag] = rules.ToEtag();
return Ok(response); return Ok(response);
} }

4
src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs

@ -11,11 +11,11 @@ using System.Linq;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Web; using Squidex.Web.Json;
namespace Squidex.Areas.Api.Controllers.Schemas.Models namespace Squidex.Areas.Api.Controllers.Schemas.Models
{ {
[JsonConverter(typeof(MyJsonInheritanceConverter<FieldPropertiesDto>), "fieldType")] [JsonConverter(typeof(TypedJsonInheritanceConverter<FieldPropertiesDto>), "fieldType")]
[KnownType(nameof(Subtypes))] [KnownType(nameof(Subtypes))]
public abstract class FieldPropertiesDto public abstract class FieldPropertiesDto
{ {

2
src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs

@ -17,7 +17,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Schemas.Models namespace Squidex.Areas.Api.Controllers.Schemas.Models
{ {
public class SchemaDto : Resource, IGenerateETag public class SchemaDto : Resource
{ {
/// <summary> /// <summary>
/// The id of the schema. /// The id of the schema.

5
src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs

@ -21,11 +21,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
/// </summary> /// </summary>
public SchemaDto[] Items { get; set; } public SchemaDto[] Items { get; set; }
public string ToEtag()
{
return Items.ToManyEtag();
}
public static SchemasDto FromSchemas(IList<ISchemaEntity> schemas, ApiController controller, string app) public static SchemasDto FromSchemas(IList<ISchemaEntity> schemas, ApiController controller, string app)
{ {
var result = new SchemasDto var result = new SchemasDto

16
src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -50,9 +50,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas
{ {
var schemas = await appProvider.GetSchemasAsync(AppId); var schemas = await appProvider.GetSchemasAsync(AppId);
var response = SchemasDto.FromSchemas(schemas, this, app); var response = Deferred.Response(() =>
{
return SchemasDto.FromSchemas(schemas, this, app);
});
Response.Headers[HeaderNames.ETag] = response.ToEtag(); Response.Headers[HeaderNames.ETag] = schemas.ToEtag();
return Ok(response); return Ok(response);
} }
@ -89,9 +92,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas
return NotFound(); return NotFound();
} }
var response = SchemaDetailsDto.FromSchemaWithDetails(schema, this, app); var response = Deferred.Response(() =>
{
return SchemaDetailsDto.FromSchemaWithDetails(schema, this, app);
});
Response.Headers[HeaderNames.ETag] = schema.Version.ToString(); Response.Headers[HeaderNames.ETag] = schema.ToEtag();
return Ok(response); return Ok(response);
} }
@ -108,7 +114,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns> /// </returns>
[HttpPost] [HttpPost]
[Route("apps/{app}/schemas/")] [Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ProducesResponseType(typeof(SchemaDetailsDto), 201)]
[ApiPermission(Permissions.AppSchemasCreate)] [ApiPermission(Permissions.AppSchemasCreate)]
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request) public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request)

2
src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs

@ -176,7 +176,7 @@ namespace Squidex.Areas.IdentityServer.Config
}, },
Claims = new List<Claim> Claims = new List<Claim>
{ {
new Claim(SquidexClaimTypes.Permissions, Permissions.Admin) new Claim(SquidexClaimTypes.Permissions, Permissions.All)
} }
}; };
} }

3
src/Squidex/Config/Domain/EntitiesServices.cs

@ -123,6 +123,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<DynamicContentWorkflow>() services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>(); .AsOptional<IContentWorkflow>();
services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<RolePermissionsProvider>() services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf(); .AsSelf();

1
src/Squidex/Config/Domain/SerializationInitializer.cs

@ -29,6 +29,7 @@ namespace Squidex.Config.Domain
{ {
this.jsonNetSerializer = jsonNetSerializer; this.jsonNetSerializer = jsonNetSerializer;
this.jsonSerializer = jsonSerializer; this.jsonSerializer = jsonSerializer;
this.ruleRegistry = ruleRegistry; this.ruleRegistry = ruleRegistry;
} }

1
src/Squidex/Config/Web/WebServices.cs

@ -51,6 +51,7 @@ namespace Squidex.Config.Web
services.AddMvc(options => services.AddMvc(options =>
{ {
options.Filters.Add<ETagFilter>(); options.Filters.Add<ETagFilter>();
options.Filters.Add<DeferredActionFilter>();
options.Filters.Add<AppResolver>(); options.Filters.Add<AppResolver>();
options.Filters.Add<MeasureResultFilter>(); options.Filters.Add<MeasureResultFilter>();
}) })

1
src/Squidex/WebStartup.cs

@ -35,6 +35,7 @@ using Squidex.Infrastructure.Translations;
using Squidex.Pipeline.Plugins; using Squidex.Pipeline.Plugins;
using Squidex.Pipeline.Robots; using Squidex.Pipeline.Robots;
using Squidex.Web; using Squidex.Web;
using Squidex.Web.Pipeline;
namespace Squidex namespace Squidex
{ {

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

@ -149,7 +149,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
this.contentForm.submitFailed(error); this.contentForm.submitFailed(error);
}); });
} else { } else {
if (this.content && !this.content.canUpdate) { if (this.content && !this.content.canUpdateAny) {
return; return;
} }
@ -183,7 +183,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
private loadContent(data: any) { private loadContent(data: any) {
this.contentForm.loadContent(data); this.contentForm.loadContent(data);
this.contentForm.setEnabled(!this.content || this.content.canUpdate); this.contentForm.setEnabled(!this.content || this.content.canUpdateAny);
} }
public discardChanges() { public discardChanges() {

1
src/Squidex/app/features/settings/declarations.ts

@ -20,6 +20,7 @@ export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component'; export * from './pages/roles/roles-page.component';
export * from './pages/workflows/workflow-step.component'; export * from './pages/workflows/workflow-step.component';
export * from './pages/workflows/workflow-transition.component'; export * from './pages/workflows/workflow-transition.component';
export * from './pages/workflows/workflow.component';
export * from './pages/workflows/workflows-page.component'; export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component'; export * from './settings-area.component';

4
src/Squidex/app/features/settings/module.ts

@ -16,7 +16,6 @@ import {
} from '@app/shared'; } from '@app/shared';
import { import {
BackupDownloadUrlPipe,
BackupDurationPipe, BackupDurationPipe,
BackupsPageComponent, BackupsPageComponent,
ClientComponent, ClientComponent,
@ -31,6 +30,7 @@ import {
RoleComponent, RoleComponent,
RolesPageComponent, RolesPageComponent,
SettingsAreaComponent, SettingsAreaComponent,
WorkflowComponent,
WorkflowsPageComponent, WorkflowsPageComponent,
WorkflowStepComponent, WorkflowStepComponent,
WorkflowTransitionComponent WorkflowTransitionComponent
@ -198,7 +198,6 @@ const routes: Routes = [
RouterModule.forChild(routes) RouterModule.forChild(routes)
], ],
declarations: [ declarations: [
BackupDownloadUrlPipe,
BackupDurationPipe, BackupDurationPipe,
BackupsPageComponent, BackupsPageComponent,
ClientComponent, ClientComponent,
@ -213,6 +212,7 @@ const routes: Routes = [
RoleComponent, RoleComponent,
RolesPageComponent, RolesPageComponent,
SettingsAreaComponent, SettingsAreaComponent,
WorkflowComponent,
WorkflowsPageComponent, WorkflowsPageComponent,
WorkflowTransitionComponent, WorkflowTransitionComponent,
WorkflowStepComponent WorkflowStepComponent

2
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -72,7 +72,7 @@
<div *ngIf="backup.stopped && !backup.isFailed"> <div *ngIf="backup.stopped && !backup.isFailed">
Download: Download:
<a href="{{backup | sqxBackupDownloadUrl}}" sqxExternalLink="noicon"> <a href="{{apiUrl.buildUrl(backup.downloadUrl)}}" sqxExternalLink="noicon">
Ready <i class="icon-external-link"></i> Ready <i class="icon-external-link"></i>
</a> </a>
</div> </div>

2
src/Squidex/app/features/settings/pages/backups/backups-page.component.ts

@ -10,6 +10,7 @@ import { timer } from 'rxjs';
import { onErrorResumeNext, switchMap } from 'rxjs/operators'; import { onErrorResumeNext, switchMap } from 'rxjs/operators';
import { import {
ApiUrlConfig,
AppsState, AppsState,
BackupDto, BackupDto,
BackupsState, BackupsState,
@ -23,6 +24,7 @@ import {
}) })
export class BackupsPageComponent extends ResourceOwner implements OnInit { export class BackupsPageComponent extends ResourceOwner implements OnInit {
constructor( constructor(
public readonly apiUrl: ApiUrlConfig,
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly backupsState: BackupsState public readonly backupsState: BackupsState
) { ) {

23
src/Squidex/app/features/settings/pages/backups/pipes.ts

@ -7,12 +7,7 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { import { BackupDto, Duration } from '@app/shared';
ApiUrlConfig,
AppsState,
BackupDto,
Duration
} from '@app/shared';
@Pipe({ @Pipe({
name: 'sqxBackupDuration', name: 'sqxBackupDuration',
@ -22,20 +17,4 @@ export class BackupDurationPipe implements PipeTransform {
public transform(backup: BackupDto) { public transform(backup: BackupDto) {
return Duration.create(backup.started, backup.stopped!).toString(); return Duration.create(backup.started, backup.stopped!).toString();
} }
}
@Pipe({
name: 'sqxBackupDownloadUrl',
pure: true
})
export class BackupDownloadUrlPipe implements PipeTransform {
constructor(
private readonly apiUrl: ApiUrlConfig,
private readonly appsState: AppsState
) {
}
public transform(backup: BackupDto) {
return this.apiUrl.buildUrl(`api/apps/${this.appsState.appName}/backups/${backup.id}`);
}
} }

4
src/Squidex/app/features/settings/pages/clients/clients-page.component.ts

@ -9,8 +9,8 @@ import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { import {
AddClientForm,
AppsState, AppsState,
AttachClientForm,
ClientDto, ClientDto,
ClientsState, ClientsState,
RolesState RolesState
@ -22,7 +22,7 @@ import {
templateUrl: './clients-page.component.html' templateUrl: './clients-page.component.html'
}) })
export class ClientsPageComponent implements OnInit { export class ClientsPageComponent implements OnInit {
public addClientForm = new AttachClientForm(this.formBuilder); public addClientForm = new AddClientForm(this.formBuilder);
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,

38
src/Squidex/app/features/settings/pages/workflows/schema-tag-converter.ts

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Converter, SchemaDto, TagValue } from '@app/shared';
export class SchemaTagConverter implements Converter {
public readonly suggestions: TagValue[];
constructor(
private readonly schemas: SchemaDto[]
) {
this.suggestions = schemas.map(x => new TagValue(x.id, x.name, x.id));
}
public convertInput(input: string): TagValue<any> | null {
const schema = this.schemas.find(x => x.name === input);
if (schema) {
return new TagValue(schema.id, schema.name, schema.id);
}
return null;
}
public convertValue(value: any): TagValue<any> | null {
const schema = this.schemas.find(x => x.id === value);
if (schema) {
return new TagValue(schema.id, schema.name, schema.id);
}
return null;
}
}

4
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

@ -13,7 +13,7 @@
[ngModelOptions]="onBlur" [ngModelOptions]="onBlur"
[ngModel]="step.color" [ngModel]="step.color"
(ngModelChange)="changeColor($event)" (ngModelChange)="changeColor($event)"
[disabled]="step.isLocked || disabled"> [disabled]="disabled">
</sqx-color-picker> </sqx-color-picker>
</div> </div>
<div class="col"> <div class="col">
@ -29,7 +29,7 @@
<small class="text-decent">(Cannot be removed)</small> <small class="text-decent">(Cannot be removed)</small>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked" [disabled]="disabled"> <button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked && workflow.steps.length > 2" [disabled]="disabled">
<i class="icon-bin2"></i> <i class="icon-bin2"></i>
</button> </button>
</div> </div>

96
src/Squidex/app/features/settings/pages/workflows/workflow.component.html

@ -0,0 +1,96 @@
<div class="table-items-row table-items-row-expandable workflow">
<div class="table-items-row-summary">
<div class="row no-gutters">
<div class="col col-name">
<span class="workflow-name">{{workflow.displayName}}</span>
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource" [ngModel]="workflow.schemaIds"
placeholder=""
styleGray="true"
styleBlank="true"
singleLine="true"
disabled="true">
</sqx-tag-editor>
</div>
<div class="col-options">
<div class="float-right">
<button type="button" class="btn btn-secondary table-items-edit-button mr-1" [class.active]="isEditing" (click)="toggleEditing()">
<i class="icon-settings"></i>
</button>
<button type="button" class="btn btn-text-danger"
[disabled]="!workflow.canDelete"
(sqxConfirmClick)="remove()"
confirmTitle="Remove workflow"
confirmText="Do you really want to remove the workflow?">
<i class="icon-bin2"></i>
</button>
</div>
</div>
</div>
</div>
<div class="table-items-row-details" *ngIf="isEditing">
<div class="table-items-row-details-tabs clearfix">
<div class="float-right">
<button type="reset" class="btn btn-text" (click)="toggleEditing()">Cancel</button>
<button type="submit" class="btn btn-primary" *ngIf="isEditable" (click)="save()">Save</button>
</div>
</div>
<div class="table-items-row-details-tab">
<sqx-form-error [error]="error"></sqx-form-error>
<div class="form-group row">
<label class="col-form-label" for="{{workflow.id}}_name">Name</label>
<div class="col">
<input class="form-control" id="{{workflow.id}}_name"
[ngModelOptions]="onBlur"
[ngModel]="workflow.name"
(ngModelChange)="rename($event)" />
<sqx-form-hint>
Optional name for the workflow.
</sqx-form-hint>
</div>
</div>
<div class="form-group row">
<label class="col-form-label" for="{{workflow.id}}_schemas">Schemas</label>
<div class="col">
<sqx-tag-editor placeholder=", to add schema"
[converter]="schemasSource"
[ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)"
[suggestedValues]="schemasSource.suggestions">
</sqx-tag-editor>
<sqx-form-hint>
Restrict this workflow to specific schemas or keep it empty for all schemas.
</sqx-form-hint>
</div>
</div>
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep"
[workflow]="workflow"
[disabled]="!workflow.canUpdate"
[roles]="roles"
[step]="step"
(makeInitial)="setInitial(step)"
(rename)="renameStep(step, $event)"
(remove)="removeStep(step)"
(transitionAdd)="addTransiton(step, $event)"
(transitionRemove)="removeTransition(step, $event)"
(transitionUpdate)="updateTransition($event)"
(update)="updateStep(step, $event)">
</sqx-workflow-step>
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate">
Add Step
</button>
</div>
</div>
</div>

34
src/Squidex/app/features/settings/pages/workflows/workflow.component.scss

@ -0,0 +1,34 @@
@import '_vars';
@import '_mixins';
.table-items-row-details {
&::before {
right: 4.55rem;
}
}
.workflow {
&-name {
@include truncate;
}
}
.col-form-label {
min-width: 4rem;
max-width: 4rem;
}
.col-tags {
padding: .6rem 1rem;
padding-bottom: 0;
}
.form-group {
margin-bottom: 2rem;
margin-left: 2rem;
}
.btn-success {
margin-bottom: 1rem;
margin-left: 2rem;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save