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.
// ==========================================================================
using System;
using System.Collections.Generic;
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(
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)
}, Status.Draft);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; }
public static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
public static readonly IReadOnlyList<Guid> EmptySchemaIds = new List<Guid>();
public static readonly Workflow Default = CreateDefault();
public static readonly Workflow Empty = new Workflow(default, EmptySteps);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; } = EmptySteps;
public IReadOnlyList<Guid> SchemaIds { get; } = EmptySchemaIds;
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;
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)

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

@ -9,6 +9,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
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]
public Workflows Set(Workflow workflow)
{
@ -35,6 +50,32 @@ namespace Squidex.Domain.Apps.Core.Contents
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()
{
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;
namespace Squidex.Domain.Apps.Core.Apps
namespace Squidex.Domain.Apps.Core
{
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)
{
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);
}

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);
Fields =
schema.Fields.ToArray(x =>
schema.Fields.Select(x =>
new JsonFieldModel
{
Id = x.Id,
@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
IsDisabled = x.IsDisabled,
Partitioning = x.Partitioning.Key,
Properties = x.RawProperties
});
}).ToArray();
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)
{
return arrayField.Fields.ToArray(x =>
return arrayField.Fields.Select(x =>
new JsonNestedFieldModel
{
Id = x.Id,
@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
IsLocked = x.IsLocked,
IsDisabled = x.IsDisabled,
Properties = x.RawProperties
});
}).ToArray();
}
return null;
@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json
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);

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, 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);
}

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
{
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;
});
case ConfigureWorkflow configureWorkflow:
return UpdateReturn(configureWorkflow, c =>
case AddWorkflow addWorkflow:
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;
});
@ -329,9 +349,19 @@ namespace Squidex.Domain.Apps.Entities.Apps
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)

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;
namespace Squidex.Web
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public interface IGenerateETag
public sealed class DeleteWorkflow : AppCommand
{
Guid Id { get; }
long Version { get; }
public Guid WorkflowId { get; set; }
}
}

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.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents;
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; }
}
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
@ -13,11 +14,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
{
public static class GuardAppWorkflows
{
public static void CanConfigure(ConfigureWorkflow command)
public static void CanAdd(AddWorkflow 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)
{
@ -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);
}
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)

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)
{
var tags = await reader.ReadJsonAttachmentAsync<TagSet>(TagsFile);
var tags = await reader.ReadJsonAttachmentAsync<TagsExport>(TagsFile);
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 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
{
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
{
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.");
GuardContent.CanCreate(ctx.Schema, c);
await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c);
await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data);
await ctx.EnrichAsync(c.Data);
@ -93,17 +93,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
case UpdateContent updateContent:
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:
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:
@ -111,9 +115,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
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.");
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c);
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c, isChangeConfirm);
if (c.DueTime.HasValue)
{
@ -121,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
else
{
if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published)
if (isChangeConfirm)
{
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 =
isProposal ?
Snapshot.DataDraft :
@ -207,22 +211,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (partial)
{
await ctx.ValidatePartialAsync(c.Data);
await ctx.ValidatePartialAsync(command.Data);
}
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)
{
ProposeUpdate(c, newData);
ProposeUpdate(command, newData);
}
else
{
Update(c, newData);
Update(command, newData);
}
}

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

@ -8,7 +8,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Microsoft.OData;
@ -23,9 +22,7 @@ using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.Queries.OData;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Shared.Identity;
#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 Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
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)
{
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)
{
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();
}
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)
{
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))
{
@ -60,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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))
{
@ -72,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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();
@ -83,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
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))
{
if (CanUse(transition, content, user))
if (CanUse(transition, content.DataDraft, user))
{
result.Add(new StatusInfo(to, GetColor(step)));
}
@ -96,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
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))
{
@ -108,17 +115,34 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (!string.IsNullOrWhiteSpace(transition.Expression))
{
return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression);
return scriptEngine.Evaluate("data", data, transition.Expression);
}
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);
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)

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)));
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)

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 void CanCreate(ISchemaEntity schema, CreateContent command)
public static async Task CanCreate(ISchemaEntity schema, IContentWorkflow contentWorkflow, CreateContent 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.");
}
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));
@ -40,10 +45,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
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));
@ -52,7 +60,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
ValidateData(command, e);
});
await ValidateCanUpdate(content, contentWorkflow);
if (!isProposal)
{
await ValidateCanUpdate(content, contentWorkflow);
}
}
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));
@ -76,20 +87,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
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)
{
e("Content has no changes to publish.", nameof(command.Status));
}
}
else
if (!content.IsPending)
{
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())
{

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<bool> CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
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);
}
public Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string group)
public Task<TagsSet> GetTagsAsync(Guid appId, string group)
{
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();
}
public Task RebuildTagsAsync(Guid appId, string group, TagSet tags)
public Task RebuildTagsAsync(Guid appId, string group, TagsExport 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, int>> GetTagsAsync();
Task<TagsSet> GetTagsAsync();
Task<TagSet> GetExportableTagsAsync();
Task<TagsExport> GetExportableTagsAsync();
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")]
public sealed class GrainState
{
public TagSet Tags { get; set; } = new TagSet();
public TagsExport Tags { get; set; } = new TagsExport();
}
public TagGrain(IStore<string> store)
@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Tags
return ClearStateAsync();
}
public Task RebuildAsync(TagSet tags)
public Task RebuildAsync(TagsExport tags)
{
State.Tags = tags;
@ -132,12 +132,14 @@ namespace Squidex.Domain.Apps.Entities.Tags
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);
}

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.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppWorkflowConfigured))]
public sealed class AppWorkflowConfigured : AppEvent
[EventType(nameof(AppWorkflowUpdated))]
public sealed class AppWorkflowUpdated : AppEvent
{
public Guid WorkflowId { 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));
}
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];
@ -65,20 +65,6 @@ namespace Squidex.Infrastructure
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)
{
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)
{
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)
{
return events.ToArray(x => eventDataFormatter.ToEventData(x, commitId, true));
return events.Map(x => eventDataFormatter.ToEventData(x, commitId, true));
}
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 AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard";
public const string AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.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 AppApi = "squidex.apps.{app}.api";

4
src/Squidex.Web/ApiExceptionFilterAttribute.cs

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

1
src/Squidex.Web/ApiPermissionAttribute.cs

@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Identity;
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.Collections.Generic;
using System.Text;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
@ -17,40 +18,54 @@ namespace Squidex.Web
{
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"))
{
var unhashed = Unhashed(items, total);
var unhashed = Unhashed(items, 0, app);
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(total);
if (items.Count > 0)
if (app != null)
{
sb.Append(items[0].Id.ToString());
sb.Append(items[0].Version);
for (var i = 1; i < items.Count; i++)
{
sb.Append(";");
sb.Append(items[i].Id.ToString());
sb.Append(items[i].Version);
}
sb.Append("_");
sb.Append(app.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)
{
@ -70,9 +85,17 @@ namespace Squidex.Web
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
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 readonly IReadOnlyDictionary<string, Type> maping;
static MyJsonInheritanceConverter()
private static readonly Lazy<Dictionary<string, Type>> DefaultMapping = new Lazy<Dictionary<string, Type>>(() =>
{
var baseName = typeof(T).Name;
var result = new Dictionary<string, Type>();
void AddType(Type type)
{
var discriminator = type.Name;
@ -36,7 +35,7 @@ namespace Squidex.Web
discriminator = discriminator.Substring(0, discriminator.Length - baseName.Length);
}
DefaultMapping[discriminator] = type;
result[discriminator] = type;
}
foreach (var attribute in typeof(T).GetCustomAttributes<KnownTypeAttribute>())
@ -66,17 +65,21 @@ namespace Squidex.Web
}
}
}
}
public MyJsonInheritanceConverter(string discriminator)
: this(discriminator, DefaultMapping)
return result;
});
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)
{
maping = mapping ?? DefaultMapping;
maping = mapping ?? DefaultMapping.Value;
}
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.Net.Http.Headers;
namespace Squidex.Web
namespace Squidex.Web.Pipeline
{
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.
// ==========================================================================
namespace Squidex.Web
namespace Squidex.Web.Pipeline
{
public sealed class ETagOptions
{

1
src/Squidex.Web/ResourceLink.cs

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

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

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

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

@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)]
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);
}

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

@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)]
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);
}
@ -67,11 +70,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ProducesResponseType(typeof(string[]), 200)]
[ApiPermission(Permissions.AppRolesRead)]
[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);
}

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

@ -5,11 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
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.Shared;
using Squidex.Web;
@ -22,9 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController
{
public AppWorkflowsController(ICommandBus commandBus)
private readonly IWorkflowsValidator workflowsValidator;
public AppWorkflowsController(ICommandBus commandBus, IWorkflowsValidator workflowsValidator)
: base(commandBus)
{
this.workflowsValidator = workflowsValidator;
}
/// <summary>
@ -36,35 +42,38 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/workflow/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsRead)]
[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);
}
/// <summary>
/// Configure workflow of the app.
/// Create a workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param>
/// <returns>
/// 200 => Workflow configured.
/// 400 => Workflow is not valid.
/// 404 => App not found.
/// 200 => Workflow updated.
/// 400 => Workflow request is not valid.
/// 404 => Workflow or app not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/workflow/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)]
[HttpPost]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)]
[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();
@ -73,12 +82,60 @@ namespace Squidex.Areas.Api.Controllers.Apps
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 result = context.Result<IAppEntity>();
var response = WorkflowResponseDto.FromApp(result, this);
var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this);
return response;
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
@ -62,9 +63,12 @@ namespace Squidex.Areas.Api.Controllers.Apps
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);
}

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 Squidex.Domain.Apps.Entities.Apps;
using Squidex.Web;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Commands;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowResponseDto : Resource
public sealed class AddWorkflowDto
{
/// <summary>
/// The workflow.
/// The name of the workflow.
/// </summary>
[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
{
Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name)
};
return result;
return new AddWorkflow { Name = Name };
}
}
}

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
{
public sealed class AppDto : Resource, IGenerateETag
public sealed class AppDto : Resource
{
/// <summary>
/// The name of the app.
@ -179,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
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))

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

@ -6,6 +6,7 @@
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps;
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)
{
var contributors = app.Contributors.ToArray(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name));
var result = new ContributorsDto
{
Items = contributors,
Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(),
};
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@ -13,22 +14,33 @@ using Squidex.Domain.Apps.Entities.Apps.Commands;
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>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public List<Guid> SchemaIds { get; set; }
/// <summary>
/// The initial step.
/// </summary>
public Status Initial { get; set; }
public ConfigureWorkflow ToCommand()
public UpdateWorkflow ToCommand(Guid id)
{
var workflow = new Workflow(
Initial,
Steps?.ToDictionary(
x => x.Key,
x => new WorkflowStep(
@ -37,9 +49,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)),
x.Value.Color,
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.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@ -17,44 +18,62 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
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>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The schema ids.
/// </summary>
public IReadOnlyList<Guid> SchemaIds { get; set; }
/// <summary>
/// The initial step.
/// </summary>
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
{
Steps = workflow.Steps.ToDictionary(
x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto
{
Transitions = x.Value.Transitions.ToDictionary(
y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
})),
Initial = workflow.Initial
};
return result.CreateLinks(controller, app);
var result = SimpleMapper.Map(workflow, new WorkflowDto { Id = id });
result.Steps = workflow.Steps.ToDictionary(
x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto
{
Transitions = x.Value.Transitions.ToDictionary(
y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
}));
return result.CreateLinks(controller, app, id);
}
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))
{
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;
}
}

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)]
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>
@ -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 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);
}
@ -138,14 +143,17 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NotFound();
}
var response = AssetDto.FromAsset(asset, this, app);
var response = Deferred.Response(() =>
{
return AssetDto.FromAsset(asset, this, app);
});
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);
}
@ -175,10 +183,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var command = new CreateAsset { File = assetFile };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetCreatedResult>();
var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate);
var response = await InvokeCommandAsync(app, command);
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 result = context.Result<IEnrichedAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
return response;
if (context.PlainResult is AssetCreatedResult created)
{
return AssetDto.FromAsset(created.Asset, this, app, created.IsDuplicate);
}
else
{
return AssetDto.FromAsset(context.Result<IEnrichedAssetEntity>(), this, app);
}
}
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
{
public sealed class AssetDto : Resource, IGenerateETag
public sealed class AssetDto : Resource
{
/// <summary>
/// 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]
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)
{
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));
}
AddGetLink("download", controller.Url<BackupContentController>(x => nameof(x.GetBackupContent), values));
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
{
public sealed class RestoreRequest
public sealed class RestoreRequestDto
{
/// <summary>
/// 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]
[Route("apps/restore/")]
[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);

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)
{
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);
}

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

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
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 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);
}
@ -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 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);
}
@ -200,10 +207,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
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);
}
@ -235,10 +242,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
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);
}
@ -267,11 +274,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
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 response = await InvokeCommandAsync(app, name, command);
@ -367,11 +369,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
return new ForbidResult();
}
var command = request.ToCommand(id);
var response = await InvokeCommandAsync(app, name, command);
@ -396,7 +393,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[HttpPut]
[Route("content/{app}/{name}/{id}/discard/")]
[ProducesResponseType(typeof(ContentsDto), 200)]
[ApiPermission(Permissions.AppContentsDiscard)]
[ApiPermission(Permissions.AppContentsDraftDiscard)]
[ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id)
{
@ -447,9 +444,9 @@ namespace Squidex.Areas.Api.Controllers.Contents
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("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("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
{
public sealed class ContentDto : Resource, IGenerateETag
public sealed class ContentDto : Resource
{
/// <summary>
/// The if of the content item.
@ -122,12 +122,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
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));
}
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));
}
@ -146,24 +146,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
}
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)
{
foreach (var next in content.Nexts)
if (content.Nexts != null)
{
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);
}
}
}
if (controller.HasPermission(Permissions.AppContentsDelete, app, schema))
{
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
return this;
}
}

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

@ -37,16 +37,6 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[Required]
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,
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));
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]
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";

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

@ -51,9 +51,12 @@ namespace Squidex.Areas.Api.Controllers.Plans
{
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);
}

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

@ -8,11 +8,11 @@
using System;
using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Web;
using Squidex.Web.Json;
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; }

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

@ -55,7 +55,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models
if (oldName != null)
{
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
{
public sealed class RuleDto : Resource, IGenerateETag
public sealed class RuleDto : Resource
{
/// <summary>
/// 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 Newtonsoft.Json;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Web;
using Squidex.Web.Json;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
[JsonConverter(typeof(MyJsonInheritanceConverter<RuleTriggerDto>), "triggerType")]
[JsonConverter(typeof(TypedJsonInheritanceConverter<RuleTriggerDto>), "triggerType")]
[KnownType(nameof(Subtypes))]
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]
public RuleDto[] Items { get; set; }
public string GenerateEtag()
{
return Items.ToManyEtag(0);
}
public static RulesDto FromRules(IEnumerable<IRuleEntity> items, ApiController controller, string app)
{
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)]
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;
@ -84,9 +87,12 @@ namespace Squidex.Areas.Api.Controllers.Rules
{
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);
}

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

@ -11,11 +11,11 @@ using System.Linq;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Web;
using Squidex.Web.Json;
namespace Squidex.Areas.Api.Controllers.Schemas.Models
{
[JsonConverter(typeof(MyJsonInheritanceConverter<FieldPropertiesDto>), "fieldType")]
[JsonConverter(typeof(TypedJsonInheritanceConverter<FieldPropertiesDto>), "fieldType")]
[KnownType(nameof(Subtypes))]
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
{
public class SchemaDto : Resource, IGenerateETag
public class SchemaDto : Resource
{
/// <summary>
/// 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>
public SchemaDto[] Items { get; set; }
public string ToEtag()
{
return Items.ToManyEtag();
}
public static SchemasDto FromSchemas(IList<ISchemaEntity> schemas, ApiController controller, string app)
{
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 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);
}
@ -89,9 +92,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas
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);
}
@ -108,7 +114,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
/// </returns>
[HttpPost]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemaDetailsDto), 200)]
[ProducesResponseType(typeof(SchemaDetailsDto), 201)]
[ApiPermission(Permissions.AppSchemasCreate)]
[ApiCosts(1)]
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>
{
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>()
.AsOptional<IContentWorkflow>();
services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>();
services.AddSingletonAs<RolePermissionsProvider>()
.AsSelf();

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

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

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

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

1
src/Squidex/WebStartup.cs

@ -35,6 +35,7 @@ using Squidex.Infrastructure.Translations;
using Squidex.Pipeline.Plugins;
using Squidex.Pipeline.Robots;
using Squidex.Web;
using Squidex.Web.Pipeline;
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);
});
} else {
if (this.content && !this.content.canUpdate) {
if (this.content && !this.content.canUpdateAny) {
return;
}
@ -183,7 +183,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
private loadContent(data: any) {
this.contentForm.loadContent(data);
this.contentForm.setEnabled(!this.content || this.content.canUpdate);
this.contentForm.setEnabled(!this.content || this.content.canUpdateAny);
}
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/workflows/workflow-step.component';
export * from './pages/workflows/workflow-transition.component';
export * from './pages/workflows/workflow.component';
export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component';

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

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

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

@ -72,7 +72,7 @@
<div *ngIf="backup.stopped && !backup.isFailed">
Download:
<a href="{{backup | sqxBackupDownloadUrl}}" sqxExternalLink="noicon">
<a href="{{apiUrl.buildUrl(backup.downloadUrl)}}" sqxExternalLink="noicon">
Ready <i class="icon-external-link"></i>
</a>
</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 {
ApiUrlConfig,
AppsState,
BackupDto,
BackupsState,
@ -23,6 +24,7 @@ import {
})
export class BackupsPageComponent extends ResourceOwner implements OnInit {
constructor(
public readonly apiUrl: ApiUrlConfig,
public readonly appsState: AppsState,
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 {
ApiUrlConfig,
AppsState,
BackupDto,
Duration
} from '@app/shared';
import { BackupDto, Duration } from '@app/shared';
@Pipe({
name: 'sqxBackupDuration',
@ -22,20 +17,4 @@ export class BackupDurationPipe implements PipeTransform {
public transform(backup: BackupDto) {
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 {
AddClientForm,
AppsState,
AttachClientForm,
ClientDto,
ClientsState,
RolesState
@ -22,7 +22,7 @@ import {
templateUrl: './clients-page.component.html'
})
export class ClientsPageComponent implements OnInit {
public addClientForm = new AttachClientForm(this.formBuilder);
public addClientForm = new AddClientForm(this.formBuilder);
constructor(
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"
[ngModel]="step.color"
(ngModelChange)="changeColor($event)"
[disabled]="step.isLocked || disabled">
[disabled]="disabled">
</sqx-color-picker>
</div>
<div class="col">
@ -29,7 +29,7 @@
<small class="text-decent">(Cannot be removed)</small>
</div>
<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>
</button>
</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