From bfc5d78bb02a31fe06b53fb10e6c7438913aeb41 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 24 Aug 2018 14:31:58 +0200 Subject: [PATCH 01/14] Fixed commata. --- src/Squidex/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index d89b3fc6c..416a4cac0 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -12,7 +12,7 @@ */ "baseUrl": "http://localhost:5000", - /* + /*7 * Set it to true to redirect the user from http to https permanently. */ "enforceHttps": false @@ -238,7 +238,7 @@ "oidcName": "OIDC", "oidcAuthority": "", "oidcClient": "", - "oidcSecret": "" + "oidcSecret": "", /* * Lock new users automatically, the administrator must unlock them. */ From 4ee456cb881886a8e0469b1959f7c7bb20989f35 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 24 Aug 2018 14:45:09 +0200 Subject: [PATCH 02/14] Published fixed. --- .../Contents/SingletonCommandMiddleware.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs index 919c2d25e..71fa52761 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs @@ -31,10 +31,12 @@ namespace Squidex.Domain.Apps.Entities.Contents var data = new NamedContentData(); var contentId = schemaId.Id; - var content = new CreateContent { Data = data, ContentId = contentId, SchemaId = schemaId, Publish = true }; + var content = new CreateContent { Data = data, ContentId = contentId, SchemaId = schemaId }; SimpleMapper.Map(createSchema, content); + content.Publish = true; + await context.CommandBus.PublishAsync(content); } } From 546f371c88f1cbfad5293b486f15357e61bfdd96 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 24 Aug 2018 15:17:52 +0200 Subject: [PATCH 03/14] Tests improved. --- .../Contents/SingletonCommandMiddlewareTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs index 466c880c3..505a56f1f 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs @@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents await sut.HandleAsync(context); - A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x is CreateContent))) + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => x.Publish == true))) .MustHaveHappened(); } From b3346bb864315e7f017d259380a151e308d0b890 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 25 Aug 2018 11:41:26 +0200 Subject: [PATCH 04/14] Unpublished flag. --- .../Actions/AzureQueueActionHandler.cs | 2 +- .../Actions/ElasticSearchActionHandler.cs | 2 +- .../HandleRules/Actions/SlackActionHandler.cs | 2 +- .../Contents/Visitors/FindExtensions.cs | 2 +- .../Templates/CreateBlogCommandMiddleware.cs | 2 +- .../CreateProfileCommandMiddleware.cs | 2 +- .../Contents/ContentQueryContext.cs | 5 +++ .../Contents/ContentQueryService.cs | 21 ++++++++-- .../QueryContext.cs | 7 ++++ .../Contents/ContentsController.cs | 5 ++- .../Schemas/Models/SchemaDetailsDto.cs | 2 +- .../ValidateContent/ContentValidationTests.cs | 2 +- .../Contents/ContentQueryServiceTests.cs | 40 +++++++++++++------ 13 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs index 72af2b839..40dc6a85b 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs @@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions { QueueConnectionString = action.ConnectionString, QueueName = queueName, - MessageBodyV2 = body, + MessageBodyV2 = body }; return (ruleDescription, ruleJob); diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs index b51b2d5bf..f07b51846 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs @@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions Password = action.Password, ContentId = contentId, IndexName = formatter.Format(action.IndexName, @event), - IndexType = formatter.Format(action.IndexType, @event), + IndexType = formatter.Format(action.IndexType, @event) }; if (contentEvent.Type == EnrichedContentEventType.Deleted || diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs index e10d7f66f..4f25f2bac 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions var ruleJob = new SlackJob { RequestUrl = action.WebhookUrl.ToString(), - RequestBodyV2 = body.ToString(Formatting.Indented), + RequestBodyV2 = body.ToString(Formatting.Indented) }; return (Description, ruleJob); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs index dd1c462b6..018136626 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { var filters = new List> { - Filter.Eq(x => x.IndexedSchemaId, schemaId), + Filter.Eq(x => x.IndexedSchemaId, schemaId) }; if (status != null) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs index b5e7f4520..99ddef8c3 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates .AddField("text", new ContentFieldData() .AddValue("iv", "Just created a blog with Squidex. I love it!")), - Publish = true, + Publish = true }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs index 4a9fcc384..fbdfc85c7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs @@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates .AddField("profession", new ContentFieldData() .AddValue("iv", "Software Developer")), - Publish = true, + Publish = true }); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs index f7461d617..547cab20e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs @@ -38,6 +38,11 @@ namespace Squidex.Domain.Apps.Entities.Contents return Clone(c => c.Base = c.Base.WithFlatten(flatten)); } + public ContentQueryContext WithUnpublished(bool unpublished) + { + return Clone(c => c.Base = c.Base.WithUnpublished(unpublished)); + } + public ContentQueryContext WithSchemaId(Guid id) { return Clone(c => c.SchemaIdOrName = id.ToString()); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index 032ab7c36..a5a62198c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -21,6 +21,8 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; +#pragma warning disable RECS0147 + namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentQueryService : IContentQueryService @@ -242,11 +244,22 @@ namespace Squidex.Domain.Apps.Entities.Contents { return StatusArchived; } - - return StatusDraftOrPublished; + else + { + return StatusDraftOrPublished; + } + } + else + { + if (context.Unpublished) + { + return StatusDraftOrPublished; + } + else + { + return StatusPublished; + } } - - return StatusPublished; } private Task FindContentByVersionAsync(Guid id, long version) diff --git a/src/Squidex.Domain.Apps.Entities/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/QueryContext.cs index 8c86dbc30..c8cd33d2a 100644 --- a/src/Squidex.Domain.Apps.Entities/QueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/QueryContext.cs @@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Entities public bool Flatten { get; private set; } + public bool Unpublished { get; set; } + public IEnumerable Languages { get; private set; } private QueryContext() @@ -34,6 +36,11 @@ namespace Squidex.Domain.Apps.Entities return new QueryContext { App = app, User = user }; } + public QueryContext WithUnpublished(bool unpublished) + { + return Clone(c => c.Unpublished = unpublished); + } + public QueryContext WithArchived(bool archived) { return Clone(c => c.Archived = archived); diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index bc88ae58b..06ea519cf 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -482,7 +482,10 @@ namespace Squidex.Areas.Api.Controllers.Contents private ContentQueryContext Context() { - return new ContentQueryContext(QueryContext.Create(App, User).WithLanguages(Request.Headers["X-Languages"])).WithFlatten(Request.Headers.ContainsKey("X-Flatten")); + return new ContentQueryContext(QueryContext.Create(App, User) + .WithLanguages(Request.Headers["X-Languages"])) + .WithFlatten(Request.Headers.ContainsKey("X-Flatten")) + .WithUnpublished(Request.Headers.ContainsKey("X-Unpublished")); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index c9c3c5909..0dc33bc92 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -142,7 +142,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models new NestedFieldDto { FieldId = nestedField.Id, - Properties = nestedFieldPropertiesDto, + Properties = nestedFieldPropertiesDto }); fieldDto.Nested.Add(nestedFieldDto); diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs index b32fe063d..896976e1b 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs @@ -340,7 +340,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent new List { new ValidationError("my-field[1].my-nested: Field is required.", "my-field[1].my-nested"), - new ValidationError("my-field[3].my-nested: Field is required.", "my-field[3].my-nested"), + new ValidationError("my-field[3].my-nested: Field is required.", "my-field[3].my-nested") }); } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs index c772d4aaf..667959238 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs @@ -165,16 +165,22 @@ namespace Squidex.Domain.Apps.Entities.Contents public static IEnumerable ManyRequestData = new[] { - new object[] { 5, 200, false, true, new[] { Status.Published } }, - new object[] { 5, 200, false, false, new[] { Status.Published } }, - new object[] { 5, 200, true, false, new[] { Status.Draft, Status.Published } }, - new object[] { 5, 200, true, true, new[] { Status.Archived } } + new object[] { true, true, true, new[] { Status.Archived } }, + new object[] { true, true, false, new[] { Status.Archived } }, + new object[] { true, false, true, new[] { Status.Draft, Status.Published } }, + new object[] { true, false, false, new[] { Status.Draft, Status.Published } }, + new object[] { false, true, true, new[] { Status.Draft, Status.Published } }, + new object[] { false, false, true, new[] { Status.Draft, Status.Published } }, + new object[] { false, false, false, new[] { Status.Published } }, + new object[] { false, true, false, new[] { Status.Published } } }; [Theory] [MemberData(nameof(ManyRequestData))] - public async Task Should_query_contents_by_query_from_repository_and_transform(int count, int total, bool isFrontend, bool archive, params Status[] status) + public async Task Should_query_contents_by_query_from_repository_and_transform(bool isFrontend, bool archive, bool unpublished, params Status[] status) { + const int count = 5, total = 200; + var contentId = Guid.NewGuid(); var content = CreateContent(contentId); @@ -187,7 +193,9 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A.Ignored)) .Returns(ResultList.Create(total, Enumerable.Repeat(content, count))); - var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty); + var ctx = context.WithSchemaId(schemaId).WithArchived(archive).WithUnpublished(unpublished); + + var result = await sut.QueryAsync(ctx, Query.Empty); Assert.Equal(contentData, result[0].Data); Assert.Equal(content.Id, result[0].Id); @@ -220,16 +228,22 @@ namespace Squidex.Domain.Apps.Entities.Contents public static IEnumerable ManyIdRequestData = new[] { - new object[] { 5, 200, false, true, new[] { Status.Published } }, - new object[] { 5, 200, false, false, new[] { Status.Published } }, - new object[] { 5, 200, true, false, new[] { Status.Draft, Status.Published } }, - new object[] { 5, 200, true, true, new[] { Status.Archived } } + new object[] { true, true, true, new[] { Status.Archived } }, + new object[] { true, true, false, new[] { Status.Archived } }, + new object[] { true, false, true, new[] { Status.Draft, Status.Published } }, + new object[] { true, false, false, new[] { Status.Draft, Status.Published } }, + new object[] { false, true, true, new[] { Status.Draft, Status.Published } }, + new object[] { false, false, true, new[] { Status.Draft, Status.Published } }, + new object[] { false, false, false, new[] { Status.Published } }, + new object[] { false, true, false, new[] { Status.Published } } }; [Theory] [MemberData(nameof(ManyIdRequestData))] - public async Task Should_query_contents_by_id_from_repository_and_transform(int count, int total, bool isFrontend, bool archive, params Status[] status) + public async Task Should_query_contents_by_id_from_repository_and_transform(bool isFrontend, bool archive, bool unpublished, params Status[] status) { + const int count = 5, total = 200; + var ids = Enumerable.Range(0, count).Select(x => Guid.NewGuid()).ToList(); SetupClaims(isFrontend); @@ -241,7 +255,9 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A>.Ignored)) .Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle())); - var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty.WithIds(ids)); + var ctx = context.WithSchemaId(schemaId).WithArchived(archive).WithUnpublished(unpublished); + + var result = await sut.QueryAsync(ctx, Query.Empty.WithIds(ids)); Assert.Equal(ids, result.Select(x => x.Id).ToList()); Assert.Equal(total, result.Total); From 2ab01256515439ade5b33b626ab78da1fc782716 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 25 Aug 2018 11:48:33 +0200 Subject: [PATCH 05/14] Unpublished content for single content. --- .../Contents/ContentQueryService.cs | 28 +++++++++++++++---- .../Contents/ContentQueryServiceTests.cs | 12 +++++--- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index a5a62198c..53e15ff75 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -72,12 +72,12 @@ namespace Squidex.Domain.Apps.Entities.Contents { var isVersioned = version > EtagVersion.Empty; - var parsedStatus = context.Base.IsFrontendClient ? StatusAll : StatusPublished; + var status = GetFindStatus(context.Base); var content = isVersioned ? await FindContentByVersionAsync(id, version) : - await FindContentAsync(context.Base, id, parsedStatus, schema); + await FindContentAsync(context.Base, id, status, schema); if (content == null || (content.Status != Status.Published && !context.Base.IsFrontendClient) || content.SchemaId.Id != schema.Id) { @@ -96,20 +96,20 @@ namespace Squidex.Domain.Apps.Entities.Contents using (Profiler.TraceMethod()) { - var parsedStatus = ParseStatus(context.Base); + var status = GetQueryStatus(context.Base); IResultList contents; if (query.Ids?.Count > 0) { - contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, new HashSet(query.Ids)); + contents = await contentRepository.QueryAsync(context.Base.App, schema, status, new HashSet(query.Ids)); contents = Sort(contents, query.Ids); } else { var parsedQuery = ParseQuery(context.Base, query.ODataQuery, schema); - contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, parsedQuery); + contents = await contentRepository.QueryAsync(context.Base.App, schema, status, parsedQuery); } return Transform(context.Base, schema, true, contents); @@ -236,7 +236,23 @@ namespace Squidex.Domain.Apps.Entities.Contents return schema; } - private static Status[] ParseStatus(QueryContext context) + private static Status[] GetFindStatus(QueryContext context) + { + if (context.IsFrontendClient) + { + return StatusAll; + } + else if (context.Unpublished) + { + return StatusDraftOrPublished; + } + else + { + return StatusPublished; + } + } + + private static Status[] GetQueryStatus(QueryContext context) { if (context.IsFrontendClient) { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs index 667959238..b68d3a632 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs @@ -105,13 +105,15 @@ namespace Squidex.Domain.Apps.Entities.Contents public static IEnumerable SingleRequestData = new[] { - new object[] { true, new[] { Status.Archived, Status.Draft, Status.Published } }, - new object[] { false, new[] { Status.Published } } + new object[] { true, true, new[] { Status.Archived, Status.Draft, Status.Published } }, + new object[] { true, false, new[] { Status.Archived, Status.Draft, Status.Published } }, + new object[] { false, true, new[] { Status.Draft, Status.Published } }, + new object[] { false, false, new[] { Status.Published } } }; [Theory] [MemberData(nameof(SingleRequestData))] - public async Task Should_return_content_from_repository_and_transform(bool isFrontend, params Status[] status) + public async Task Should_return_content_from_repository_and_transform(bool isFrontend, bool unpublished, params Status[] status) { var contentId = Guid.NewGuid(); var content = CreateContent(contentId); @@ -124,7 +126,9 @@ namespace Squidex.Domain.Apps.Entities.Contents A.CallTo(() => contentRepository.FindContentAsync(app, schema, A.That.IsSameSequenceAs(status), contentId)) .Returns(content); - var result = await sut.FindContentAsync(context.WithSchemaId(schemaId), contentId); + var ctx = context.WithSchemaId(schemaId).WithUnpublished(unpublished); + + var result = await sut.FindContentAsync(ctx, contentId); Assert.Equal(contentTransformed, result.Data); Assert.Equal(content.Id, result.Id); From 054b91b7e6dd0f24beacf1e217841bb2080ea6d7 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 25 Aug 2018 17:04:45 +0200 Subject: [PATCH 06/14] Batch endpoint for graphql. --- .../Contents/Edm/EdmModelBuilder.cs | 4 +- .../Contents/GraphQL/CachingGraphQLService.cs | 61 +++++++++++++------ .../Contents/GraphQL/GraphQLModel.cs | 2 +- .../Contents/GraphQL/IGraphQLService.cs | 4 +- .../UsageTracking/CachingUsageTracker.cs | 16 ++--- .../Contents/ContentsController.cs | 39 ++++++++++-- 6 files changed, 92 insertions(+), 34 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs index ea8ffbe18..f2d9c133f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm { public class EdmModelBuilder : CachingProviderBase { + private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60); + public EdmModelBuilder(IMemoryCache cache) : base(cache) { @@ -32,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm var result = Cache.GetOrCreate(cacheKey, entry => { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + entry.AbsoluteExpirationRelativeToNow = CacheTime; return BuildEdmModel(schema.SchemaDef, app.PartitionResolver()); }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 8109c91d7..0c0f298c5 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -23,7 +23,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private readonly IAssetQueryService assetQuery; private readonly IAppProvider appProvider; - public CachingGraphQLService(IMemoryCache cache, + public CachingGraphQLService( + IMemoryCache cache, IAppProvider appProvider, IAssetQueryService assetQuery, IContentQueryService contentQuery, @@ -41,39 +42,65 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.urlGenerator = urlGenerator; } - public async Task<(object Data, object[] Errors)> QueryAsync(QueryContext context, GraphQLQuery query) + public async Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery[] queries) + { + Guard.NotNull(context, nameof(context)); + Guard.NotNull(queries, nameof(queries)); + + var model = await GetModelAsync(context.App); + + var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + + var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); + + return (result.Any(x => x.HasError), result.Select(x => x.Response).ToArray()); + } + + public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query) { Guard.NotNull(context, nameof(context)); Guard.NotNull(query, nameof(query)); + var model = await GetModelAsync(context.App); + + var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + + var result = await QueryInternalAsync(model, ctx, query); + + return result; + } + + private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query) + { if (string.IsNullOrWhiteSpace(query.Query)) { - return (new object(), new object[0]); + return (false, new { Data = new object() }); } - var modelContext = await GetModelAsync(context.App); - - var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + var result = await model.ExecuteAsync(ctx, query); - return await modelContext.ExecuteAsync(ctx, query); + if (result.Errors?.Any() == true) + { + return (false, new { result.Data, result.Errors }); + } + else + { + return (false, new { result.Data }); + } } - private async Task GetModelAsync(IAppEntity app) + private Task GetModelAsync(IAppEntity app) { var cacheKey = CreateCacheKey(app.Id, app.Version.ToString()); - var modelContext = Cache.Get(cacheKey); - - if (modelContext == null) + return Cache.GetOrCreateAsync(cacheKey, async entry => { - var allSchemas = await appProvider.GetSchemasAsync(app.Id); + entry.AbsoluteExpirationRelativeToNow = CacheDuration; - modelContext = new GraphQLModel(app, allSchemas.Where(x => x.IsPublished), urlGenerator); - - Cache.Set(cacheKey, modelContext, CacheDuration); - } + var allSchemas = await appProvider.GetSchemasAsync(app.Id); - return modelContext; + return new GraphQLModel(app, allSchemas, urlGenerator); + }); } private static object CreateCacheKey(Guid appId, string etag) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index 2b8daf4e6..e971d8b46 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL assetType = new AssetGraphType(this); assetListType = new ListGraphType(new NonNullGraphType(assetType)); - schemasById = schemas.ToDictionary(x => x.Id); + schemasById = schemas.Where(x => x.IsPublished).ToDictionary(x => x.Id); graphQLSchema = BuildSchema(this); graphQLSchema.RegisterValueConverter(JsonConverter.Instance); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs index dbf71ee12..e348c75f1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public interface IGraphQLService { - Task<(object Data, object[] Errors)> QueryAsync(QueryContext context, GraphQLQuery query); + Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery[] queries); + + Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery query); } } diff --git a/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs index 2f287d7db..785ee87fb 100644 --- a/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs +++ b/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs @@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.UsageTracking { public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker { - private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private readonly IUsageTracker inner; public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache) @@ -35,22 +35,18 @@ namespace Squidex.Infrastructure.UsageTracking return inner.TrackAsync(key, weight, elapsedMs); } - public async Task GetMonthlyCallsAsync(string key, DateTime date) + public Task GetMonthlyCallsAsync(string key, DateTime date) { Guard.NotNull(key, nameof(key)); var cacheKey = string.Concat(key, date); - if (Cache.TryGetValue(cacheKey, out var result)) + return Cache.GetOrCreateAsync(cacheKey, entry => { - return result; - } + entry.AbsoluteExpirationRelativeToNow = CacheDuration; - result = await inner.GetMonthlyCallsAsync(key, date); - - Cache.Set(cacheKey, result, CacheTime); - - return result; + return inner.GetMonthlyCallsAsync(key, date); + }); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 06ea519cf..960c348b5 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// GraphQL endpoint. /// /// The name of the app. - /// The graphql endpoint. + /// The graphql query. /// /// 200 => Contents retrieved or mutated. /// 404 => Schema or app not found. @@ -67,13 +67,44 @@ namespace Squidex.Areas.Api.Controllers.Contents { var result = await graphQl.QueryAsync(Context().Base, query); - if (result.Errors?.Length > 0) + if (result.HasError) { - return BadRequest(new { result.Data, result.Errors }); + return BadRequest(result.Response); } else { - return Ok(new { result.Data }); + return Ok(result.Response); + } + } + + /// + /// GraphQL endpoint with batch support. + /// + /// The name of the app. + /// The graphql queries. + /// + /// 200 => Contents retrieved or mutated. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [HttpPost] + [Route("content/{app}/graphql/")] + [ApiCosts(2)] + public async Task PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch) + { + var result = await graphQl.QueryAsync(Context().Base, batch); + + if (result.HasError) + { + return BadRequest(result.Response); + } + else + { + return Ok(result.Response); } } From 0fa0b77ffb2b6df2b29976bf611f99504096589b Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 25 Aug 2018 18:16:47 +0200 Subject: [PATCH 07/14] Tests for graphl batching. --- .../Contents/GraphQL/CachingGraphQLService.cs | 8 +-- .../Contents/GraphQL/IGraphQLService.cs | 4 +- .../Contents/GraphQL/GraphQLQueriesTests.cs | 65 +++++++++++++++++-- .../Contents/GraphQL/GraphQLTestBase.cs | 14 ++-- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 0c0f298c5..dce94972e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.urlGenerator = urlGenerator; } - public async Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery[] queries) + public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries) { Guard.NotNull(context, nameof(context)); Guard.NotNull(queries, nameof(queries)); @@ -74,18 +74,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { if (string.IsNullOrWhiteSpace(query.Query)) { - return (false, new { Data = new object() }); + return (false, new { data = new object() }); } var result = await model.ExecuteAsync(ctx, query); if (result.Errors?.Any() == true) { - return (false, new { result.Data, result.Errors }); + return (false, new { data = result.Data, errors = result.Errors }); } else { - return (false, new { result.Data }); + return (false, new { data = result.Data }); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs index e348c75f1..693f1fabf 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs @@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public interface IGraphQLService { - Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery[] queries); + Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries); - Task<(bool HasError, object[] Response)> QueryAsync(QueryContext context, GraphQLQuery query); + Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query); } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 29e11b77d..a85850c88 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -8,6 +8,7 @@ using System; using System.Threading.Tasks; using FakeItEasy; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; using Xunit; @@ -173,7 +174,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public async Task Should_return_single_asset_when_finding_asset() { var assetId = Guid.NewGuid(); - var asset = CreateAsset(Guid.NewGuid()); + var asset = CreateAsset(assetId); var query = $@" query {{ @@ -713,6 +714,61 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_make_multiple_queries() + { + var assetId1 = Guid.NewGuid(); + var assetId2 = Guid.NewGuid(); + var asset1 = CreateAsset(assetId1); + var asset2 = CreateAsset(assetId2); + + var query1 = $@" + query {{ + findAsset(id: ""{assetId1}"") {{ + id + }} + }}"; + var query2 = $@" + query {{ + findAsset(id: ""{assetId2}"") {{ + id + }} + }}"; + + A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId1)) + .Returns(asset1); + A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId2)) + .Returns(asset2); + + var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 }); + + var expected = new object[] + { + new + { + data = new + { + findAsset = new + { + id = asset1.Id + } + } + }, + new + { + data = new + { + findAsset = new + { + id = asset2.Id + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_not_return_data_when_field_not_part_of_content() { @@ -742,12 +798,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); - var expected = new - { - data = (object)null - }; + var json = JToken.FromObject(result); - AssertResult(expected, result, false); + Assert.Null(json["data"]); } private QueryContext MatchsAssetContext() diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index f25465a81..7d95261dc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; using NodaTime.Extensions; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps; @@ -174,17 +175,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return asset; } - protected static void AssertResult(object expected, (object Data, object[] Errors) result, bool checkErrors = true) + protected static void AssertResult(object expected, (bool HasErrors, object Response) result, bool checkErrors = true) { - if (checkErrors && (result.Errors != null && result.Errors.Length > 0)) + if (checkErrors && result.HasErrors) { - throw new InvalidOperationException(result.Errors[0]?.ToString()); + throw new InvalidOperationException(NewMethod(result)); } - var resultJson = JsonConvert.SerializeObject(new { data = result.Data }, Formatting.Indented); + var resultJson = JsonConvert.SerializeObject(result.Response, Formatting.Indented); var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented); Assert.Equal(expectJson, resultJson); } + + private static string NewMethod((bool HasErrors, object Response) result) + { + return JsonConvert.SerializeObject(result).ToString(); + } } } From 92a7f07de7b2c89b286c7169f54db2b805b4f53e Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 28 Aug 2018 09:35:28 +0200 Subject: [PATCH 08/14] Backup support for UI settings. --- .../Apps/AppUISettingsGrain.cs | 111 ++++++++++++++ .../Apps/BackupApps.cs | 29 +++- .../Apps/IAppUISettingsGrain.cs | 25 +++ .../CreateIdentityCommandMiddleware.cs | 2 - .../Areas/Api/Controllers/UI/UIController.cs | 67 ++++++-- .../geolocation-editor.component.ts | 6 +- src/Squidex/app/shared/internal.ts | 1 + src/Squidex/app/shared/module.ts | 2 + .../app/shared/services/ui.service.spec.ts | 42 +++-- src/Squidex/app/shared/services/ui.service.ts | 39 ++--- src/Squidex/app/shared/state/ui.state.spec.ts | 102 +++++++++++++ src/Squidex/app/shared/state/ui.state.ts | 144 ++++++++++++++++++ .../Apps/AppUISettingsGrainTests.cs | 125 +++++++++++++++ .../Contents/GraphQL/GraphQLTestBase.cs | 1 - 14 files changed, 646 insertions(+), 50 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs create mode 100644 src/Squidex/app/shared/state/ui.state.spec.ts create mode 100644 src/Squidex/app/shared/state/ui.state.ts create mode 100644 tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs new file mode 100644 index 000000000..3c88584cd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -0,0 +1,111 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain + { + private readonly IStore store; + private IPersistence persistence; + private JObject state = new JObject(); + + public AppUISettingsGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(Guid key) + { + persistence = store.WithSnapshots(GetType(), key, x => state = x); + + return persistence.ReadAsync(); + } + + public Task> GetAsync() + { + return Task.FromResult(state.AsJ()); + } + + public Task SetAsync(J setting) + { + state = setting; + + return persistence.WriteSnapshotAsync(state); + } + + public Task SetAsync(string path, J value) + { + var container = GetContainer(path, out var key); + + if (container == null) + { + throw new InvalidOperationException("Path does not lead to an object."); + } + + container[key] = value; + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveAsync(string path) + { + var container = GetContainer(path, out var key); + + if (container != null) + { + container.Remove(key); + } + + return persistence.WriteSnapshotAsync(state); + } + + private JObject GetContainer(string path, out string key) + { + Guard.NotNullOrEmpty(path, nameof(path)); + + var segments = path.Split('.'); + + key = segments[segments.Length - 1]; + + var current = state; + + if (segments.Length > 1) + { + foreach (var segment in segments.Take(segments.Length - 1)) + { + if (!current.TryGetValue(segment, out var temp)) + { + temp = new JObject(); + + current[segment] = temp; + } + + if (temp is JObject next) + { + current = next; + } + else + { + return null; + } + } + } + + return current; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index cd5339431..d285ed517 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public sealed class BackupApps : BackupHandlerWithStore { private const string UsersFile = "Users.json"; + private const string SettingsFile = "Settings.json"; private readonly IGrainFactory grainFactory; private readonly IUserResolver userResolver; private readonly IAppsByNameIndex appsByNameIndex; @@ -69,9 +70,10 @@ namespace Squidex.Domain.Apps.Entities.Apps } } - public override Task BackupAsync(Guid appId, BackupWriter writer) + public override async Task BackupAsync(Guid appId, BackupWriter writer) { - return WriterUsersAsync(writer); + await WriteUsersAsync(writer); + await WriteSettingsAsync(writer, appId); } public async override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) @@ -120,6 +122,11 @@ namespace Squidex.Domain.Apps.Entities.Apps } } + public override Task RestoreAsync(Guid appId, BackupReader reader) + { + return ReadSettingsAsync(reader, appId); + } + private async Task ReserveAppAsync(Guid appId) { if (!(isReserved = await appsByNameIndex.ReserveAppAsync(appId, appName))) @@ -167,11 +174,25 @@ namespace Squidex.Domain.Apps.Entities.Apps usersWithEmail = json.ToObject>(); } - private Task WriterUsersAsync(BackupWriter writer) + private async Task WriteUsersAsync(BackupWriter writer) { var json = JObject.FromObject(usersWithEmail); - return writer.WriteJsonAsync(UsersFile, json); + await writer.WriteJsonAsync(UsersFile, json); + } + + private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) + { + var json = await grainFactory.GetGrain(appId).GetAsync(); + + await writer.WriteJsonAsync(SettingsFile, json); + } + + private async Task ReadSettingsAsync(BackupReader reader, Guid appId) + { + var json = await reader.ReadJsonAttachmentAsync(SettingsFile); + + await grainFactory.GetGrain(appId).SetAsync((JObject)json); } public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs new file mode 100644 index 000000000..23b680e51 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppUISettingsGrain : IGrainWithGuidKey + { + Task> GetAsync(); + + Task SetAsync(string path, J value); + + Task SetAsync(J setting); + + Task RemoveAsync(string path); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs index bcdfc7f09..24d07ac4d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs @@ -6,9 +6,7 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; using Squidex.Domain.Apps.Entities.Schemas.Commands; diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index 2c87e8624..80c4ea6c6 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -5,11 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; using NSwag.Annotations; +using Orleans; using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Config; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure.Commands; using Squidex.Pipeline; @@ -23,29 +27,74 @@ namespace Squidex.Areas.Api.Controllers.UI public sealed class UIController : ApiController { private readonly MyUIOptions uiOptions; + private readonly IGrainFactory grainFactory; - public UIController(ICommandBus commandBus, IOptions uiOptions) + public UIController(ICommandBus commandBus, IOptions uiOptions, IGrainFactory grainFactory) : base(commandBus) { this.uiOptions = uiOptions.Value; + this.grainFactory = grainFactory; } /// /// Get ui settings. /// + /// The name of the app. + /// + /// 200 => UI settings returned. + /// 404 => App not found. + /// [HttpGet] - [Route("ui/settings/")] + [Route("apps/{app}/ui/settings/")] [ProducesResponseType(typeof(UISettingsDto), 200)] [ApiCosts(0)] - public IActionResult GetSettings() + public async Task GetSettings(string app) { - var dto = new UISettingsDto - { - MapType = uiOptions.Map?.Type ?? "OSM", - MapKey = uiOptions.Map?.GoogleMaps?.Key - }; + var result = await grainFactory.GetGrain(App.Id).GetAsync(); - return Ok(dto); + result.Value["mapType"] = uiOptions.Map?.Type ?? "OSM"; + result.Value["mapKey"] = uiOptions.Map?.GoogleMaps?.Key; + + return Ok(result.Value); + } + + /// + /// Set ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// The name of the value. + /// + /// 200 => UI setting set. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task PutSetting(string app, string key, [FromBody] JToken value) + { + await grainFactory.GetGrain(App.Id).SetAsync(key, value); + + return NoContent(); + } + + /// + /// Remove ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// + /// 200 => UI setting removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task DeleteSetting(string app, string key) + { + await grainFactory.GetGrain(App.Id).RemoveAsync(key); + + return NoContent(); } } } diff --git a/src/Squidex/app/shared/components/geolocation-editor.component.ts b/src/Squidex/app/shared/components/geolocation-editor.component.ts index d64a39950..7239840db 100644 --- a/src/Squidex/app/shared/components/geolocation-editor.component.ts +++ b/src/Squidex/app/shared/components/geolocation-editor.component.ts @@ -11,7 +11,7 @@ import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/f import { ResourceLoaderService, Types, - UIService, + UIState, ValidatorsEx } from '@app/shared/internal'; @@ -72,7 +72,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi constructor( private readonly resourceLoader: ResourceLoaderService, private readonly formBuilder: FormBuilder, - private readonly uiService: UIService + private readonly uiState: UIState ) { } @@ -158,7 +158,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi } public ngAfterViewInit() { - this.uiService.getSettings() + this.uiState.settings .subscribe(settings => { this.isGoogleMaps = settings.mapType === 'GoogleMaps'; diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 51fb48eb8..669db06e0 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -61,6 +61,7 @@ export * from './state/rule-events.state'; export * from './state/rules.state'; export * from './state/schemas.forms'; export * from './state/schemas.state'; +export * from './state/ui.state'; export * from './utils/messages'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index a32ac6428..d156853f6 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -67,6 +67,7 @@ import { SchemasService, SchemasState, UIService, + UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, @@ -179,6 +180,7 @@ export class SqxSharedModule { SchemasService, SchemasState, UIService, + UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, diff --git a/src/Squidex/app/shared/services/ui.service.spec.ts b/src/Squidex/app/shared/services/ui.service.spec.ts index 2e27ece4d..1d4c280b4 100644 --- a/src/Squidex/app/shared/services/ui.service.spec.ts +++ b/src/Squidex/app/shared/services/ui.service.spec.ts @@ -34,28 +34,22 @@ describe('UIService', () => { it('should make get request to get settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - let settings1: UISettingsDto; - let settings2: UISettingsDto; + let settings: UISettingsDto; - uiService.getSettings().subscribe(result => { - settings1 = result; + uiService.getSettings('my-app').subscribe(result => { + settings = result; }); const response: UISettingsDto = { mapType: 'OSM', mapKey: '' }; - const req = httpMock.expectOne('http://service/p/api/ui/settings'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); req.flush(response); - uiService.getSettings().subscribe(result => { - settings2 = result; - }); - - expect(settings1!).toEqual(response); - expect(settings2!).toEqual(response); + expect(settings!).toEqual(response); })); it('should return default settings when error occurs', @@ -63,11 +57,11 @@ describe('UIService', () => { let settings: UISettingsDto; - uiService.getSettings().subscribe(result => { + uiService.getSettings('my-app').subscribe(result => { settings = result; }); - const req = httpMock.expectOne('http://service/p/api/ui/settings'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -76,4 +70,26 @@ describe('UIService', () => { expect(settings!).toBeDefined(); })); + + it('should make put request to set value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.putSetting('my-app', 'root.nested', 123).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); + + it('should make delete request to remove value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteSetting('my-app', 'root.nested').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/ui.service.ts b/src/Squidex/app/shared/services/ui.service.ts index 605b73747..dc590d332 100644 --- a/src/Squidex/app/shared/services/ui.service.ts +++ b/src/Squidex/app/shared/services/ui.service.ts @@ -8,38 +8,41 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { ApiUrlConfig } from '@app/framework'; export interface UISettingsDto { mapType: string; - mapKey: string; + mapKey?: string; } @Injectable() export class UIService { - private settings: UISettingsDto; - constructor( private readonly http: HttpClient, private readonly apiUrl: ApiUrlConfig ) { } - public getSettings(): Observable { - if (this.settings) { - return of(this.settings); - } else { - const url = this.apiUrl.buildUrl(`api/ui/settings`); - - return this.http.get(url).pipe( - catchError(error => { - return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' }); - }), - tap(settings => { - this.settings = settings; - })); - } + public getSettings(appName: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`); + + return this.http.get(url).pipe( + catchError(_ => { + return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' }); + })); + } + + public putSetting(appName: string, key: string, value: any): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); + + return this.http.put(url, value); + } + + public deleteSetting(appName: string, key: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); + + return this.http.delete(url); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.spec.ts b/src/Squidex/app/shared/state/ui.state.spec.ts new file mode 100644 index 000000000..1da9cc5d1 --- /dev/null +++ b/src/Squidex/app/shared/state/ui.state.spec.ts @@ -0,0 +1,102 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { of } from 'rxjs'; +import { IMock, It, Mock, Times } from 'typemoq'; + +import { AppsState } from '@app/shared'; + +import { UIService } from './../services/ui.service'; +import { UIState } from './ui.state'; + +describe('UIState', () => { + const app = 'my-app'; + + const oldSettings = { + mapType: 'OSM' + }; + + let appsState: IMock; + let uiService: IMock; + let uiState: UIState; + + beforeEach(() => { + appsState = Mock.ofType(); + + appsState.setup(x => x.appName) + .returns(() => app); + + uiService = Mock.ofType(); + + uiService.setup(x => x.getSettings(app)) + .returns(() => of(oldSettings)); + + uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) + .returns(() => of({})); + + uiService.setup(x => x.deleteSetting(app, It.isAnyString())) + .returns(() => of({})); + + uiState = new UIState(appsState.object, uiService.object); + }); + + it('should load settings', () => { + expect(uiState.snapshot.settings).toEqual(oldSettings); + }); + + it('should add value to snapshot when set', () => { + uiState.set('root.nested', 123); + + expect(uiState.snapshot.settings).toEqual({ + mapType: 'OSM', + root: { + nested: 123 + } + }); + + uiState.get('root', {}).subscribe(x => { + expect(x).toEqual({ nested: 123 }); + }); + + uiState.get('root.nested', 0).subscribe(x => { + expect(x).toEqual(123); + }); + + uiState.get('root.notfound', 1337).subscribe(x => { + expect(x).toEqual(1337); + }); + + uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); + }); + + it('should remove value from snapshot when removed', () => { + uiState.set('root.nested1', 123); + uiState.set('root.nested2', 123); + uiState.remove('root.nested1'); + + expect(uiState.snapshot.settings).toEqual({ + mapType: 'OSM', + root: { + nested2: 123 + } + }); + + uiState.get('root', {}).subscribe(x => { + expect(x).toEqual({ nested2: 123 }); + }); + + uiState.get('root.nested2', 0).subscribe(x => { + expect(x).toEqual(123); + }); + + uiState.get('root.nested1', 1337).subscribe(x => { + expect(x).toEqual(1337); + }); + + uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.ts b/src/Squidex/app/shared/state/ui.state.ts new file mode 100644 index 000000000..c7edafdf4 --- /dev/null +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -0,0 +1,144 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map, tap } from 'rxjs/operators'; + +import { State, Types } from '@app/framework'; + +import { AppsState } from './apps.state'; + +import { UIService, UISettingsDto } from './../services/ui.service'; + +interface Snapshot { + settings: object & any; +} + +@Injectable() +export class UIState extends State { + public settings = + this.changes.pipe(map(x => x.settings), + distinctUntilChanged()); + + public get(path: string, defaultValue: T) { + return this.settings.pipe(map(x => this.getValue(x, path, defaultValue)), + distinctUntilChanged()); + } + + constructor( + private readonly appsState: AppsState, + private readonly uiService: UIService + ) { + super({ settings: { mapType: 'OSM' } }); + + if (appsState.selectedApp && Types.isFunction(appsState.selectedApp.subscribe)) { + appsState.selectedApp.subscribe(app => { + if (app) { + this.load(true); + } + }); + } else { + this.load(true); + } + } + + public load(reset = false): Observable { + if (!reset) { + this.resetState(); + } + + return this.loadInternal(); + } + + private loadInternal(): Observable { + return this.uiService.getSettings(this.appName).pipe( + tap(dtos => { + return this.next({ settings: dtos }); + })); + } + + public set(path: string, value: any) { + const { key, current, root } = this.getContainer(path); + + if (current && key) { + this.uiService.putSetting(this.appName, path, value).subscribe(); + + current[key] = value; + + this.next({ settings: root }); + } + } + + public remove(path: string) { + const { key, current, root } = this.getContainer(path); + + if (current && key) { + this.uiService.deleteSetting(this.appName, path).subscribe(); + + delete current[key]; + + this.next({ settings: root }); + } + } + + private getContainer(path: string) { + const segments = path.split('.'); + + let current = { ...this.snapshot.settings }; + + const root = current; + + if (segments.length > 0) { + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + + let temp = current[segment]; + + if (!temp) { + temp = {}; + } else { + temp = { ...temp }; + } + + current[segment] = temp; + + if (!Types.isObject(temp)) { + return { key: null, current: null, root: null }; + } + + current = temp; + } + } + + return { key: segments[segments.length - 1], current, root }; + } + + private getValue(setting: object & UISettingsDto, path: string, defaultValue: T) { + const segments = path.split('.'); + + let current = setting; + + for (let segment of segments) { + let temp = current[segment]; + + if (temp) { + current[segment] = temp; + } else { + return defaultValue; + } + + current = temp; + } + + return current; + } + + private get appName() { + return this.appsState.appName; + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs new file mode 100644 index 000000000..178137bd2 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettingsGrainTests + { + private readonly IStore store = A.Fake>(); + private readonly IPersistence persistence = A.Fake>(); + private readonly AppUISettingsGrain sut; + + public AppUISettingsGrainTests() + { + A.CallTo(() => store.WithSnapshots(A.Ignored, A.Ignored, A>.Ignored)) + .Returns(persistence); + + sut = new AppUISettingsGrain(store); + sut.OnActivateAsync(Guid.Empty).Wait(); + } + + [Fact] + public async Task Should_set_setting() + { + await sut.SetAsync(new JObject(new JProperty("key", 15)).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("key", 15)); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_set_root_value() + { + await sut.SetAsync("key", ((JToken)123).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("key", 123)); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_remove_root_value() + { + await sut.SetAsync("key", ((JToken)123).AsJ()); + await sut.RemoveAsync("key"); + + var actual = await sut.GetAsync(); + + var expected = new JObject(); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_set_nested_value() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("root", + new JObject( + new JProperty("nested", 123)))); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_remove_nested_value() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + await sut.RemoveAsync("root.nested"); + + var actual = await sut.GetAsync(); + + var expected = + new JObject( + new JProperty("root", new JObject())); + + Assert.Equal(expected.ToString(), actual.Value.ToString()); + } + + [Fact] + public async Task Should_throw_exception_if_nested_not_an_object() + { + await sut.SetAsync("root.nested", ((JToken)123).AsJ()); + + await Assert.ThrowsAsync(() => sut.SetAsync("root.nested.value", ((JToken)123).AsJ())); + } + + [Fact] + public Task Should_do_nothing_if_deleting_and_nested_not_found() + { + return sut.RemoveAsync("root.nested"); + } + + [Fact] + public Task Should_do_nothing_if_deleting_and_key_not_found() + { + return sut.RemoveAsync("root"); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 7d95261dc..0d152bbbc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -13,7 +13,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; using NodaTime.Extensions; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps; From a41090e99766a41bf73a96a07161fcbf45ceeb06 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 28 Aug 2018 20:07:47 +0200 Subject: [PATCH 09/14] Save queries. --- .../Apps/AppUISettingsGrain.cs | 20 +- .../Apps/IAppUISettingsGrain.cs | 2 +- .../Controllers/UI/Models/UpdateSettingDto.cs | 19 + .../Areas/Api/Controllers/UI/UIController.cs | 6 +- .../assets/pages/assets-page.component.html | 8 +- .../assets/pages/assets-page.component.scss | 21 - .../contents/contents-page.component.html | 34 +- .../pages/contents/contents-page.component.ts | 37 +- .../pages/contents/search-form.component.html | 37 +- .../pages/contents/search-form.component.scss | 10 +- .../pages/contents/search-form.component.ts | 48 +- src/Squidex/app/shared/internal.ts | 1 + src/Squidex/app/shared/services/ui.service.ts | 2 +- .../app/shared/state/contents.forms.ts | 14 +- .../app/shared/state/schema-queries.spec.ts | 79 + .../app/shared/state/schema-queries.ts | 78 + src/Squidex/app/shared/state/ui.state.ts | 15 +- src/Squidex/app/theme/_panels.scss | 38 + .../app/theme/icomoon/demo-files/demo.css | 8 +- src/Squidex/app/theme/icomoon/demo.html | 1052 ++++---- .../app/theme/icomoon/fonts/icomoon.eot | Bin 25320 -> 25736 bytes .../app/theme/icomoon/fonts/icomoon.svg | 3 + .../app/theme/icomoon/fonts/icomoon.ttf | Bin 25156 -> 25572 bytes .../app/theme/icomoon/fonts/icomoon.woff | Bin 25232 -> 25648 bytes src/Squidex/app/theme/icomoon/selection.json | 2266 +++++++++-------- src/Squidex/app/theme/icomoon/style.css | 118 +- .../Apps/AppUISettingsGrainTests.cs | 4 +- 27 files changed, 2211 insertions(+), 1709 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs create mode 100644 src/Squidex/app/shared/state/schema-queries.spec.ts create mode 100644 src/Squidex/app/shared/state/schema-queries.ts diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs index 3c88584cd..ea1e17408 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -18,8 +18,14 @@ namespace Squidex.Domain.Apps.Entities.Apps public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain { private readonly IStore store; - private IPersistence persistence; - private JObject state = new JObject(); + private IPersistence persistence; + private State state = new State(); + + [CollectionName("UISettings")] + public sealed class State + { + public JObject Settings { get; set; } = new JObject(); + } public AppUISettingsGrain(IStore store) { @@ -30,19 +36,19 @@ namespace Squidex.Domain.Apps.Entities.Apps public override Task OnActivateAsync(Guid key) { - persistence = store.WithSnapshots(GetType(), key, x => state = x); + persistence = store.WithSnapshots(GetType(), key, x => state = x); return persistence.ReadAsync(); } public Task> GetAsync() { - return Task.FromResult(state.AsJ()); + return Task.FromResult(state.Settings.AsJ()); } - public Task SetAsync(J setting) + public Task SetAsync(J settings) { - state = setting; + state.Settings = settings; return persistence.WriteSnapshotAsync(state); } @@ -81,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps key = segments[segments.Length - 1]; - var current = state; + var current = state.Settings; if (segments.Length > 1) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs index 23b680e51..38fde5c74 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps Task SetAsync(string path, J value); - Task SetAsync(J setting); + Task SetAsync(J settings); Task RemoveAsync(string path); } diff --git a/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs b/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs new file mode 100644 index 000000000..8262de435 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json.Linq; + +namespace Squidex.Areas.Api.Controllers.UI.Models +{ + public sealed class UpdateSettingDto + { + /// + /// The value for the setting. + /// + public JToken Value { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index 80c4ea6c6..0c1d38e01 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -22,7 +22,9 @@ namespace Squidex.Areas.Api.Controllers.UI /// /// Manages ui settings and configs. /// + [ApiAuthorize] [ApiExceptionFilter] + [AppApi] [SwaggerTag(nameof(UI))] public sealed class UIController : ApiController { @@ -71,9 +73,9 @@ namespace Squidex.Areas.Api.Controllers.UI [HttpPut] [Route("apps/{app}/ui/settings/{key}")] [ApiCosts(0)] - public async Task PutSetting(string app, string key, [FromBody] JToken value) + public async Task PutSetting(string app, string key, [FromBody] UpdateSettingDto request) { - await grainFactory.GetGrain(App.Id).SetAsync(key, value); + await grainFactory.GetGrain(App.Id).SetAsync(key, request.Value); return NoContent(); } diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 38718404e..607200788 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -23,18 +23,18 @@ -
- + -
+
-
\ No newline at end of file + + + +
+ + + Name your query + + + +
+ + + +
+
+ + + + + +
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/contents/search-form.component.scss b/src/Squidex/app/features/content/pages/contents/search-form.component.scss index 20c685cc2..4eb03572e 100644 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.scss +++ b/src/Squidex/app/features/content/pages/contents/search-form.component.scss @@ -10,7 +10,7 @@ } .form-control-expandable { - padding-right: 1.5rem; + padding-right: 3rem; } .form-horizontal { @@ -26,6 +26,14 @@ text-align: right; } +.save-search { + @include absolute(8px, 24px, auto, auto); + color: $color-border-dark !important; + font-size: .9rem; + font-weight: normal; + cursor: pointer !important; +} + .expand-search { @include absolute(8px, 8px, auto, auto); color: $color-border-dark !important; diff --git a/src/Squidex/app/features/content/pages/contents/search-form.component.ts b/src/Squidex/app/features/content/pages/contents/search-form.component.ts index babf2991b..200b7c404 100644 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.ts +++ b/src/Squidex/app/features/content/pages/contents/search-form.component.ts @@ -5,10 +5,12 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { FormBuilder, FormControl } from '@angular/forms'; -import { ModalModel } from '@app/shared'; +import { ModalModel, SaveQueryForm, SchemaQueries } from '@app/shared'; +import { Observable } from 'rxjs'; +import { shareReplay } from 'rxjs/operators'; @Component({ selector: 'sqx-search-form', @@ -16,7 +18,10 @@ import { ModalModel } from '@app/shared'; templateUrl: './search-form.component.html', changeDetection: ChangeDetectionStrategy.OnPush }) -export class SearchFormComponent implements OnChanges { +export class SearchFormComponent implements OnChanges, OnInit { + @Input() + public queries: SchemaQueries; + @Input() public query = ''; @@ -29,6 +34,9 @@ export class SearchFormComponent implements OnChanges { @Output() public archivedChanged = new EventEmitter(); + @Input() + public schemaName = ''; + @Input() public canArchive = true; @@ -39,6 +47,9 @@ export class SearchFormComponent implements OnChanges { public formClass = 'form-inline search-form'; public contentsFilter = new FormControl(); + public contentsFilterValue = this.contentsFilter.valueChanges.pipe(shareReplay(1)); + + public saveKey: Observable; public searchModal = new ModalModel(); public searchForm = @@ -48,21 +59,44 @@ export class SearchFormComponent implements OnChanges { odataSearch: '' }); + public saveQueryDialog = new ModalModel(); + public saveQueryForm = new SaveQueryForm(this.formBuilder); + constructor( private readonly formBuilder: FormBuilder ) { } - public search() { - this.invalidate(this.contentsFilter.value); - - this.queryChanged.emit(this.contentsFilter.value); + public ngOnInit() { + this.saveKey = this.queries.getSaveKey(this.contentsFilter.valueChanges); } public ngOnChanges() { this.invalidate(this.query); } + public saveQuery() { + this.saveQueryDialog.show(); + } + + public saveQueryComplete() { + const value = this.saveQueryForm.submit(); + + if (value) { + this.queries.add(value.name, this.contentsFilter.value); + + this.saveQueryForm.submitCompleted(); + } + + this.saveQueryDialog.hide(); + } + + public search() { + this.invalidate(this.contentsFilter.value); + + this.queryChanged.emit(this.contentsFilter.value); + } + private invalidate(query: string) { if (query === this.contentsFilter.value) { return; diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 669db06e0..d5bae2b7e 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -61,6 +61,7 @@ export * from './state/rule-events.state'; export * from './state/rules.state'; export * from './state/schemas.forms'; export * from './state/schemas.state'; +export * from './state/schema-queries'; export * from './state/ui.state'; export * from './utils/messages'; diff --git a/src/Squidex/app/shared/services/ui.service.ts b/src/Squidex/app/shared/services/ui.service.ts index dc590d332..9a8a43c80 100644 --- a/src/Squidex/app/shared/services/ui.service.ts +++ b/src/Squidex/app/shared/services/ui.service.ts @@ -37,7 +37,7 @@ export class UIService { public putSetting(appName: string, key: string, value: any): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); - return this.http.put(url, value); + return this.http.put(url, { value }); } public deleteSetting(appName: string, key: string): Observable { diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index cad79f703..348124935 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -8,7 +8,7 @@ // tslint:disable:prefer-for-of -import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { DateTime, @@ -36,6 +36,18 @@ import { TagsFieldPropertiesDto } from './../services/schemas.types'; +export class SaveQueryForm extends Form { + constructor(formBuilder: FormBuilder) { + super(formBuilder.group({ + name: ['', + [ + Validators.required + ] + ] + })); + } +} + export class FieldFormatter implements FieldPropertiesVisitor { constructor( private readonly value: any diff --git a/src/Squidex/app/shared/state/schema-queries.spec.ts b/src/Squidex/app/shared/state/schema-queries.spec.ts new file mode 100644 index 000000000..9041ac821 --- /dev/null +++ b/src/Squidex/app/shared/state/schema-queries.spec.ts @@ -0,0 +1,79 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { BehaviorSubject } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; + +import { Query, SchemaQueries } from './schema-queries'; +import { UIState } from './ui.state'; + +describe('SchemaQueries', () => { + const schema = 'my-schema'; + + let uiState: IMock; + let filter = new BehaviorSubject(''); + let queries = new BehaviorSubject({}); + let schemaQueries: SchemaQueries; + + beforeEach(() => { + uiState = Mock.ofType(); + + uiState.setup(x => x.get('schemas.my-schema.queries', {})) + .returns(() => queries); + + queries.next({ + key1: 'query1', + key2: 'query2' + }); + + schemaQueries = new SchemaQueries(uiState.object, schema); + }); + + it('should load queries', () => { + let converted: Query[]; + + schemaQueries.queries.subscribe(x => { + converted = x; + }); + + expect(converted!).toEqual([ + { + name: 'key1', + nameSortable: 'KEY1', + filter: 'query1' + }, { + name: 'key2', + nameSortable: 'KEY2', + filter: 'query2' + } + ]); + }); + + it('should provide key', () => { + let key: string; + + schemaQueries.getSaveKey(filter).subscribe(x => { + key = x!; + }); + + filter.next('query2'); + + expect(key!).toEqual('key2'); + }); + + it('should forward add call to state', () => { + schemaQueries.add('key3', 'filter3'); + + uiState.verify(x => x.set('schemas.my-schema.queries.key3', 'filter3'), Times.once()); + }); + + it('should forward remove call to state', () => { + schemaQueries.remove('key3'); + + uiState.verify(x => x.remove('schemas.my-schema.queries.key3'), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/schema-queries.ts b/src/Squidex/app/shared/state/schema-queries.ts new file mode 100644 index 000000000..defa41ec1 --- /dev/null +++ b/src/Squidex/app/shared/state/schema-queries.ts @@ -0,0 +1,78 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { combineLatest, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UIState } from './ui.state'; + +export interface Query { + name: string; + nameSortable?: string; + filter: string; +} + +export class SchemaQueries { + public queries: Observable; + + public defaultQueries: Query[] = [{ + name: 'All (newest first)', filter: '' + }, { + name: 'All (oldest first)', filter: '$orderby=lastModified desc' + }]; + + constructor( + private readonly uiState: UIState, + private readonly schemaName: string + ) { + this.queries = this.uiState.get(`schemas.${this.schemaName}.queries`, {}).pipe( + map(x => { + let queries: Query[] = Object.keys(x).map(y => ({ name: y, filter: x[y] })); + + for (let query of queries) { + query.nameSortable = query.name.toUpperCase(); + } + + queries = queries.sort((a, b) => { + if (a.nameSortable! < b.nameSortable!) { + return -1; + } + if (a.nameSortable! > b.nameSortable!) { + return 1; + } + return 0; + }); + + return queries; + }) + ); + } + + public add(key: string, filter: string) { + this.uiState.set(`schemas.${this.schemaName}.queries.${key}`, filter); + } + + public remove(key: string) { + this.uiState.remove(`schemas.${this.schemaName}.queries.${key}`); + } + + public getSaveKey(filter$: Observable): Observable { + return combineLatest(this.queries, filter$).pipe( + map(project => { + const filter = project[1]; + + if (filter) { + for (let query of project[0]) { + if (query.filter === filter) { + return query.name; + } + } + } + return null; + })); + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.ts b/src/Squidex/app/shared/state/ui.state.ts index c7edafdf4..84a633a81 100644 --- a/src/Squidex/app/shared/state/ui.state.ts +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -6,8 +6,7 @@ */ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { State, Types } from '@app/framework'; @@ -47,19 +46,15 @@ export class UIState extends State { } } - public load(reset = false): Observable { + public load(reset = false) { if (!reset) { this.resetState(); } - return this.loadInternal(); - } - - private loadInternal(): Observable { - return this.uiService.getSettings(this.appName).pipe( - tap(dtos => { + this.uiService.getSettings(this.appName) + .subscribe(dtos => { return this.next({ settings: dtos }); - })); + }); } public set(path: string, value: any) { diff --git a/src/Squidex/app/theme/_panels.scss b/src/Squidex/app/theme/_panels.scss index 6124ea873..9631aed47 100644 --- a/src/Squidex/app/theme/_panels.scss +++ b/src/Squidex/app/theme/_panels.scss @@ -280,6 +280,44 @@ } } } + + &-section { + border-top: 1px solid $color-border; + padding: 1rem; + } + + &-item { + & { + padding: .25rem 0; + font-size: .9rem; + font-weight: normal; + } + + &-remove { + visibility: hidden; + } + + &.active { + font-weight: bold; + } + + &.active, + &:hover { + background: $color-background; + } + + &:hover { + .sidebar-item-remove { + visibility: visible; + } + } + } +} + +a { + &.sidebar-item { + cursor: pointer !important; + } } // diff --git a/src/Squidex/app/theme/icomoon/demo-files/demo.css b/src/Squidex/app/theme/icomoon/demo-files/demo.css index 9c149f296..8823845c8 100644 --- a/src/Squidex/app/theme/icomoon/demo-files/demo.css +++ b/src/Squidex/app/theme/icomoon/demo-files/demo.css @@ -147,16 +147,16 @@ p { font-size: 16px; } .fs1 { - font-size: 24px; + font-size: 32px; } .fs2 { - font-size: 32px; + font-size: 24px; } .fs3 { - font-size: 28px; + font-size: 32px; } .fs4 { - font-size: 32px; + font-size: 28px; } .fs5 { font-size: 32px; diff --git a/src/Squidex/app/theme/icomoon/demo.html b/src/Squidex/app/theme/icomoon/demo.html index 3b417bb03..993a5be49 100644 --- a/src/Squidex/app/theme/icomoon/demo.html +++ b/src/Squidex/app/theme/icomoon/demo.html @@ -9,20 +9,20 @@
-

Font Name: icomoon (Glyphs: 95)

+

Font Name: icomoon (Glyphs: 98)

-

Grid Size: 24

+

Grid Size: 16

- + - icon-backup + icon-star-full
- - + +
liga: @@ -31,14 +31,14 @@
- + - icon-support + icon-star-empty
- - + +
liga: @@ -47,14 +47,14 @@
- + - icon-control-RichText + icon-twitter
- - + +
liga: @@ -63,289 +63,289 @@
- + - icon-download + icon-action-Tweet
- - + +
liga:
-
-
-

Grid Size: Unknown

-
+
- + - icon-action-Medium + icon-hour-glass
- - + +
liga:
-
+
- + - icon-circle + icon-spinner
- - + +
liga:
-
+
- + - icon-action-Fastly + icon-clock
- - + +
liga:
-
+
- + - icon-control-Slug + icon-bin2
- - + +
liga:
-
+
- + - icon-action-Algolia + icon-earth
- - + +
liga: - +
-
+
- + - icon-type-Tags + icon-elapsed
- - + +
liga:
-
+
- + - icon-activity + icon-google
- - + +
liga:
-
+
- + - icon-history + icon-lock
- - + +
liga:
-
+
- + - icon-time + icon-microsoft
- - + +
liga:
-
+
- + - icon-add + icon-action-AzureQueue
- - + +
liga:
-
+
- + - icon-plus + icon-pause
- - + +
liga:
-
+
- + - icon-check-circle + icon-play
- - + +
liga:
-
+
- + - icon-check-circle-filled + icon-reset
- - + +
liga:
-
+
- + - icon-close + icon-settings2
- - + +
liga:
-
+
- + - icon-type-References + icon-timeout
- - + +
liga:
-
+
- + - icon-control-Checkbox + icon-unlocked
- - + +
liga:
+
+
+

Grid Size: 24

- + - icon-control-Dropdown + icon-backup
- - + +
liga: @@ -354,14 +354,14 @@
- + - icon-control-Input + icon-support
- - + +
liga: @@ -370,14 +370,14 @@
- + - icon-control-Radio + icon-control-RichText
- - + +
liga: @@ -386,577 +386,641 @@
- + - icon-control-TextArea + icon-download
- - + +
liga:
-
+
+
+

Grid Size: Unknown

+
- + - icon-control-Toggle + icon-action-Medium
- - + +
liga:
-
+
- + - icon-copy + icon-circle
- - + +
liga:
-
+
- + - icon-dashboard + icon-action-Fastly
- - + +
liga:
-
+
- + - icon-delete + icon-control-Slug
- - + +
liga:
-
+
- + - icon-bin + icon-action-Algolia
- - + +
liga:
-
+
- + - icon-delete-filled + icon-type-Tags
- - + +
liga:
-
+
- + - icon-document-delete + icon-activity
- - + +
liga:
-
+
- + - icon-document-disable + icon-history
- - + +
liga:
-
+
- + - icon-document-publish + icon-time
- - + +
liga:
-
+
- + - icon-drag + icon-add
- - + +
liga:
-
+
- + - icon-filter + icon-plus
- - + +
liga:
-
+
- + - icon-github + icon-check-circle
- - + +
liga:
-
+
- + - icon-help + icon-check-circle-filled
- - + +
liga:
-
+
- + - icon-location + icon-close
- - + +
liga:
-
+
- + - icon-control-Map + icon-type-References
- - + +
liga:
-
+
- + - icon-type-Geolocation + icon-control-Checkbox
- - + +
liga:
-
+
-
- - + +
liga:
-
+
- + - icon-media + icon-control-Input
- - + +
liga:
-
+
- + - icon-type-Assets + icon-control-Radio
- - + +
liga:
-
+
- + - icon-trigger-AssetChanged + icon-control-TextArea
- - + +
liga:
-
+
- + - icon-more + icon-control-Toggle
- - + +
liga:
-
+
- + - icon-dots + icon-copy
- - + +
liga:
-
+
- + - icon-pencil + icon-dashboard
- - + +
liga:
-
+
- + - icon-reference + icon-delete
- - + + +
+
+ liga: + +
+
+
+
+ + + + icon-bin +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-delete-filled +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-document-delete +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-document-disable +
+
+ +
liga:
-
+
- + - icon-schemas + icon-document-publish
- - + +
liga:
-
+
- + - icon-search + icon-drag
- - + +
liga:
-
+
- + - icon-settings + icon-filter
- - + +
liga:
-
+
- + - icon-type-Boolean + icon-github
- - + +
liga:
-
+
- + - icon-type-DateTime + icon-help
- - + +
liga:
-
+
- + - icon-type-Json + icon-location
- - + +
liga:
-
+
- + - icon-json + icon-control-Map
- - + +
liga:
-
+
- + - icon-type-Number + icon-type-Geolocation
- - + +
liga:
-
+
- + - icon-type-String + icon-logo
- - + +
liga:
-
+
- + - icon-user + icon-media
- - + +
liga:
-
-
-

Grid Size: 14

- + - icon-single-content + icon-type-Assets
- - + +
liga: @@ -965,14 +1029,14 @@
- + - icon-multiple-content + icon-trigger-AssetChanged
- - + +
liga: @@ -981,14 +1045,14 @@
- + - icon-type-Array + icon-more
- - + +
liga: @@ -997,14 +1061,14 @@
- + - icon-exclamation + icon-dots
- - + +
liga: @@ -1013,14 +1077,14 @@
- + - icon-action-ElasticSearch + icon-pencil
- - + +
liga: @@ -1029,14 +1093,14 @@
- + - icon-action-Slack + icon-reference
- - + +
liga: @@ -1045,14 +1109,14 @@
- + - icon-orleans + icon-schemas
- - + +
liga: @@ -1061,14 +1125,14 @@
- + - icon-document-lock + icon-search
- - + +
liga: @@ -1077,14 +1141,14 @@
- + - icon-document-unpublish + icon-settings
- - + +
liga: @@ -1093,14 +1157,14 @@
- + - icon-angle-down + icon-type-Boolean
- - + +
liga: @@ -1109,14 +1173,14 @@
- + - icon-angle-left + icon-type-DateTime
- - + +
liga: @@ -1125,14 +1189,14 @@
- + - icon-angle-right + icon-type-Json
- - + +
liga: @@ -1141,14 +1205,14 @@
- + - icon-angle-up + icon-json
- - + +
liga: @@ -1157,14 +1221,14 @@
- + - icon-api + icon-type-Number
- - + +
liga: @@ -1173,14 +1237,14 @@
- + - icon-assets + icon-type-String
- - + +
liga: @@ -1189,257 +1253,257 @@
- + - icon-bug + icon-user
- - + +
liga:
-
+
+
+

Grid Size: 14

+
- + - icon-caret-down + icon-single-content
- - + +
liga:
-
+
- + - icon-caret-left + icon-multiple-content
-
- - +
+ +
liga:
-
+
- + - icon-caret-right + icon-type-Array
- - + +
liga:
-
+
- + - icon-caret-up + icon-exclamation
- - + +
liga:
-
+
- + - icon-contents + icon-action-ElasticSearch
- - + +
liga:
-
+
- + - icon-trigger-ContentChanged + icon-action-Slack
- - + +
liga:
-
+
- + - icon-control-Date + icon-orleans
- - + +
liga:
-
+
- + - icon-control-DateTime + icon-document-lock
- - + +
liga:
-
+
- + - icon-control-Markdown + icon-document-unpublish
- - + +
liga:
-
+
- + - icon-grid + icon-angle-down
- - + +
liga:
-
+
- + - icon-list + icon-angle-left
- - + +
liga:
-
+
- + - icon-user-o + icon-angle-right
- - + +
liga:
-
+
- + - icon-rules + icon-angle-up
- - + +
liga:
-
+
- + - icon-action-Webhook + icon-api
- - + +
liga:
-
-
-

Grid Size: 16

- + - icon-hour-glass + icon-assets
- - + +
liga: @@ -1448,14 +1512,14 @@
- + - icon-spinner + icon-bug
- - + +
liga: @@ -1464,14 +1528,14 @@
- + - icon-clock + icon-caret-down
- - + +
liga: @@ -1480,14 +1544,14 @@
- + - icon-bin2 + icon-caret-left
- - + +
liga: @@ -1496,30 +1560,30 @@
- + - icon-earth + icon-caret-right
- - + +
liga: - +
- + - icon-elapsed + icon-caret-up
- - + +
liga: @@ -1528,14 +1592,14 @@
- + - icon-google + icon-contents
- - + +
liga: @@ -1544,14 +1608,14 @@
- + - icon-lock + icon-trigger-ContentChanged
- - + +
liga: @@ -1560,14 +1624,14 @@
- + - icon-microsoft + icon-control-Date
- - + +
liga: @@ -1576,14 +1640,14 @@
- + - icon-action-AzureQueue + icon-control-DateTime
- - + +
liga: @@ -1592,14 +1656,14 @@
- + - icon-pause + icon-control-Markdown
- - + +
liga: @@ -1608,14 +1672,14 @@
- + - icon-play + icon-grid
- - + +
liga: @@ -1624,14 +1688,14 @@
- + - icon-reset + icon-list
- - + +
liga: @@ -1640,14 +1704,14 @@
- + - icon-settings2 + icon-user-o
- - + +
liga: @@ -1656,14 +1720,14 @@
- + - icon-timeout + icon-rules
- - + +
liga: @@ -1672,14 +1736,14 @@
- + - icon-unlocked + icon-action-Webhook
- - + +
liga: diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index 7d637f8990f194cefdf7fb70f9311affd3c82407..d3d2848311ef2866f3c4b0a8fb4675eddde93fca 100644 GIT binary patch delta 688 zcmZWmT}V@57=GV#wjHKBXC5~CEih%{AvO;?F3vcx@)7J>J$m09qgEuQJ+P7UppXU`!(3 zI;~|UmU^wMUK~rEYsDQoQ^~k-X8Tg<=Pvx9$vE7IBJ|zr`6lNe3PwBmd^zO z!Td~iQBkm^qk;Lvi>xEzt3Sedymew+mV>rN8!L)&MTxl8X7y4t3N}RSn-xqfgUR!Z zKf<7^UTownYV66?sWiuPu2`!vD-HbPJq`R2YKY`~wa6puGqXMuR11YdwSbU7S_uRu z8pW>a^Jt~Iy1tI16#(Y1u#@6{(#XY>lh}fs`~N;w`0U@TWV4k`|GzDT>dK9S4e#!* zcjJH_SM**SV5QP>zjef(_y)Bm^|6=?zT zHGq10a`KZC{ZFL!Ffhb#0m`4qO{^$jsA7x)T2TY!E952Srq1-@{0-#K0jh5+$S*Eo zU}HIf{DD!Kb(%mp|IE!2OuUhsH^u}r%j{1oxE;@L^Ob=c zWIY4J^|mxPJy{{aoH2QGL_!DSL{5WYT FBLJ$JQAPj& diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg index ff07500d5..d4bcfdb28 100644 --- a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg +++ b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg @@ -99,6 +99,9 @@ + + + diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index c486ad6d3b49d3af49fe608f9197c9cd64652a75..d3f0a0fa98cf8465b7aca6679d482f0a1a2f3f66 100644 GIT binary patch delta 708 zcmZWm-%C?*6hG(Q&D(VM?x&l^oYuB`V{>b|wlSD1i6CTPWe+Clye{WRrxzSyfy{>r zp_gP38nS#ygMNUpd<*JfkiPWLLs3CJ=pP_S4=z}pYeZ4sFW>Lye9k$a^F4=erGh)J zu>c4F4KN2R1bh3AcN)z63-YLEIGvM~PqB|*0nkBwVoaV^2#v(&2@j1;T+i&?+nXhR z7hrHRJ1SqXl;1rANcB@Zk|l?Z(T)+PIJmR9{LGD2>rLX%NwYXHmF||GREkajIY|j` za`KDYX49n#_ z@|OzgP@n!l0$+U$! zqf{UgJ|Tt)HQ?xKI}+5lo_0IV`F)ZYGn!bYyDKpn3`)Eqs_LF-sfvym{Jxc30uRB%xB)jTH+q_Vb19`nqs2Zu5Z$_CR0q3g7!#D2rva|0Jy$L9S$UCI#u1`QguV|f2~%k~?KyMs~8l#dO~WAS0? jt+RdCFZdJwoxn<9hjP^K%wRtp|WAtPnMrGD%0^$5KHfXSO}sf{^5!k^9gLGb5|4@L%IV(HlLH4vGGhdT7+4|G Oyyp7*s%* zlZiDwBe$f2fgu6NWt;)RRZB%$a`Kab>iiOc0u1Lsc=eIVa=D2WK#P+=j!^*PTE?in z#N1S%SO-vJ8wk(z;{08ZUt9w8i2z(3Gmyp1d~z}mV>e^mBW1sZ0Zk-Cs)+wfFXBq$Z{?FvLj!)tG^>aJE2W22c1gO6Ug!7h)wB+O`1J(H@0M#&@0Ac?VsXe)g6+nv~k!)iDEE%*@9p^DuTZMo*r>sLVP|Ae?{Z<^znpk((o9 zf|+IZCl%a|=ePOFz|8_w%fN8G4d@*xJ^4huIb-ssgbv2ZQxcEy=*sEd(vt&+R`TY& Hq(h7V store = A.Fake>(); - private readonly IPersistence persistence = A.Fake>(); + private readonly IPersistence persistence = A.Fake>(); private readonly AppUISettingsGrain sut; public AppUISettingsGrainTests() { - A.CallTo(() => store.WithSnapshots(A.Ignored, A.Ignored, A>.Ignored)) + A.CallTo(() => store.WithSnapshots(A.Ignored, A.Ignored, A>.Ignored)) .Returns(persistence); sut = new AppUISettingsGrain(store); From dd3d3e825dd095722fc3c8cad123c4c6088ae0e4 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 28 Aug 2018 20:15:59 +0200 Subject: [PATCH 10/14] Queries fixed --- src/Squidex/app/shared/state/schema-queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Squidex/app/shared/state/schema-queries.ts b/src/Squidex/app/shared/state/schema-queries.ts index defa41ec1..c1c950f21 100644 --- a/src/Squidex/app/shared/state/schema-queries.ts +++ b/src/Squidex/app/shared/state/schema-queries.ts @@ -22,7 +22,7 @@ export class SchemaQueries { public defaultQueries: Query[] = [{ name: 'All (newest first)', filter: '' }, { - name: 'All (oldest first)', filter: '$orderby=lastModified desc' + name: 'All (oldest first)', filter: '$orderby=lastModified asc' }]; constructor( From 3899e257bdd608b2760bb5020baf524d49db8944 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 29 Aug 2018 10:38:23 +0200 Subject: [PATCH 11/14] Fix graphql. --- Dockerfile | 2 +- .../Areas/Api/Controllers/Contents/ContentsController.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41268f581..1a5abe4c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ WORKDIR /app # add libuv RUN apk add --no-cache libuv \ -&& ln -s /usr/lib/libuv.so.1 /usr/lib/libuv.so + && ln -s /usr/lib/libuv.so.1 /usr/lib/libuv.so # Copy from build stage COPY --from=builder /out/alpine . diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 960c348b5..7728f6ff6 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -92,7 +92,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [MustBeAppReader] [HttpGet] [HttpPost] - [Route("content/{app}/graphql/")] + [Route("content/{app}/graphql/batch")] [ApiCosts(2)] public async Task PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch) { From e9a5e5c2aefa84232c131f8c2dbde81ba771b008 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Wed, 29 Aug 2018 15:44:37 +0200 Subject: [PATCH 12/14] Improved search form. --- .../assets/pages/assets-page.component.html | 42 +++++++++++---- .../assets/pages/assets-page.component.ts | 28 +++++++--- .../app/features/content/declarations.ts | 1 - src/Squidex/app/features/content/module.ts | 6 +-- .../contents/contents-page.component.html | 52 +++++++++---------- .../pages/contents/contents-page.component.ts | 6 +-- .../content/shared/assets-editor.component.ts | 4 +- .../shared/contents-selector.component.html | 4 +- .../components/assets-selector.component.html | 20 ++++--- .../components/assets-selector.component.ts | 9 +--- .../components}/search-form.component.html | 36 +++++++------ .../components}/search-form.component.scss | 0 .../components}/search-form.component.ts | 20 +++++-- src/Squidex/app/shared/declarations.ts | 1 + src/Squidex/app/shared/internal.ts | 2 +- src/Squidex/app/shared/module.ts | 5 +- src/Squidex/app/shared/state/assets.state.ts | 4 ++ ...schema-queries.spec.ts => queries.spec.ts} | 24 ++++----- .../state/{schema-queries.ts => queries.ts} | 10 ++-- 19 files changed, 166 insertions(+), 108 deletions(-) rename src/Squidex/app/{features/content/pages/contents => shared/components}/search-form.component.html (79%) rename src/Squidex/app/{features/content/pages/contents => shared/components}/search-form.component.scss (100%) rename src/Squidex/app/{features/content/pages/contents => shared/components}/search-form.component.ts (93%) rename src/Squidex/app/shared/state/{schema-queries.spec.ts => queries.spec.ts} (73%) rename src/Squidex/app/shared/state/{schema-queries.ts => queries.ts} (85%) diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 607200788..382985711 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -6,16 +6,25 @@ - +
+
+ - - + +
+
+ -
- -
+ + +
+
@@ -26,7 +35,7 @@ @@ -43,5 +52,20 @@
+ + diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.ts b/src/Squidex/app/features/assets/pages/assets-page.component.ts index 3a3e18f69..1b595550f 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.ts +++ b/src/Squidex/app/features/assets/pages/assets-page.component.ts @@ -11,7 +11,12 @@ import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { onErrorResumeNext } from 'rxjs/operators'; -import { AppsState, AssetsState } from '@app/shared'; +import { + AppsState, + AssetsState, + Queries, + UIState +} from '@app/shared'; @Component({ selector: 'sqx-assets-page', @@ -21,9 +26,12 @@ import { AppsState, AssetsState } from '@app/shared'; export class AssetsPageComponent implements OnInit { public assetsFilter = new FormControl(); + public queries = new Queries(this.uiState, 'assets'); + constructor( public readonly appsState: AppsState, - public readonly assetsState: AssetsState + public readonly assetsState: AssetsState, + private readonly uiState: UIState ) { } @@ -35,18 +43,18 @@ export class AssetsPageComponent implements OnInit { this.assetsState.load(true).pipe(onErrorResumeNext()).subscribe(); } - public search() { - this.assetsState.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); - } - - public resetTags() { - this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe(); + public search(query: string) { + this.assetsState.search(query).pipe(onErrorResumeNext()).subscribe(); } public toggleTag(tag: string) { this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe(); } + public resetTags() { + this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe(); + } + public goNext() { this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe(); } @@ -54,5 +62,9 @@ export class AssetsPageComponent implements OnInit { public goPrev() { this.assetsState.goPrev().pipe(onErrorResumeNext()).subscribe(); } + + public isSelectedQuery(query: string) { + return query === this.assetsState.snapshot.assetsQuery || (!query && !this.assetsState.assetsQuery); + } } diff --git a/src/Squidex/app/features/content/declarations.ts b/src/Squidex/app/features/content/declarations.ts index cd59c22c5..55c77b7bd 100644 --- a/src/Squidex/app/features/content/declarations.ts +++ b/src/Squidex/app/features/content/declarations.ts @@ -9,7 +9,6 @@ export * from './pages/content/content-field.component'; export * from './pages/content/content-history.component'; export * from './pages/content/content-page.component'; export * from './pages/contents/contents-page.component'; -export * from './pages/contents/search-form.component'; export * from './pages/schemas/schemas-page.component'; export * from './shared/array-editor.component'; diff --git a/src/Squidex/app/features/content/module.ts b/src/Squidex/app/features/content/module.ts index c3cea97be..dc2dafdab 100644 --- a/src/Squidex/app/features/content/module.ts +++ b/src/Squidex/app/features/content/module.ts @@ -32,8 +32,7 @@ import { DueTimeSelectorComponent, FieldEditorComponent, ReferencesEditorComponent, - SchemasPageComponent, - SearchFormComponent + SchemasPageComponent } from './declarations'; const routes: Routes = [ @@ -100,8 +99,7 @@ const routes: Routes = [ DueTimeSelectorComponent, FieldEditorComponent, ReferencesEditorComponent, - SchemasPageComponent, - SearchFormComponent + SchemasPageComponent ] }) export class SqxFeatureContentModule { } \ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index cea850b1c..b94743d7d 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -14,19 +14,21 @@
+ +
- - -
@@ -122,32 +124,28 @@ - - + - diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts index e5aa81be4..53e09eb8f 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts @@ -17,8 +17,8 @@ import { ImmutableArray, LanguagesState, ModalModel, + Queries, SchemaDetailsDto, - SchemaQueries, SchemasState, UIState } from '@app/shared'; @@ -36,7 +36,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit { private selectedSchemaSubscription: Subscription; public schema: SchemaDetailsDto; - public schemaQueries: SchemaQueries; + public schemaQueries: Queries; public searchModal = new ModalModel(); @@ -76,7 +76,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit { this.resetSelection(); this.schema = schema!; - this.schemaQueries = new SchemaQueries(this.uiState, this.schema.name); + this.schemaQueries = new Queries(this.uiState, `schemas.${this.schema.name}`); this.contentsState.init().pipe(onErrorResumeNext()).subscribe(); }); diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.ts b/src/Squidex/app/features/content/shared/assets-editor.component.ts index 9eaa13625..cb022c704 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.ts +++ b/src/Squidex/app/features/content/shared/assets-editor.component.ts @@ -14,8 +14,8 @@ import { AppsState, AssetDto, AssetsService, + DialogModel, ImmutableArray, - ModalModel, Types } from '@app/shared'; @@ -35,7 +35,7 @@ export class AssetsEditorComponent implements ControlValueAccessor { private callChange = (v: any) => { /* NOOP */ }; private callTouched = () => { /* NOOP */ }; - public assetsDialog = new ModalModel(); + public assetsDialog = new DialogModel(); public newAssets = ImmutableArray.empty(); public oldAssets = ImmutableArray.empty(); diff --git a/src/Squidex/app/features/content/shared/contents-selector.component.html b/src/Squidex/app/features/content/shared/contents-selector.component.html index a7506a629..bd927225f 100644 --- a/src/Squidex/app/features/content/shared/contents-selector.component.html +++ b/src/Squidex/app/features/content/shared/contents-selector.component.html @@ -11,10 +11,10 @@
- + expandable="true"> diff --git a/src/Squidex/app/shared/components/assets-selector.component.html b/src/Squidex/app/shared/components/assets-selector.component.html index 7754d1b2c..639f0447e 100644 --- a/src/Squidex/app/shared/components/assets-selector.component.html +++ b/src/Squidex/app/shared/components/assets-selector.component.html @@ -4,13 +4,19 @@ - - -
- -
+
+
+ +
+
+ + +
+
diff --git a/src/Squidex/app/shared/components/assets-selector.component.ts b/src/Squidex/app/shared/components/assets-selector.component.ts index 68d86692d..0f9266273 100644 --- a/src/Squidex/app/shared/components/assets-selector.component.ts +++ b/src/Squidex/app/shared/components/assets-selector.component.ts @@ -8,7 +8,6 @@ // tslint:disable:prefer-for-of import { Component, EventEmitter, OnInit, Output } from '@angular/core'; -import { FormControl } from '@angular/forms'; import { onErrorResumeNext } from 'rxjs/operators'; import { @@ -32,8 +31,6 @@ export class AssetsSelectorComponent implements OnInit { @Output() public selected = new EventEmitter(); - public assetsFilter = new FormControl(''); - constructor( public readonly state: AssetsDialogState ) { @@ -41,16 +38,14 @@ export class AssetsSelectorComponent implements OnInit { public ngOnInit() { this.state.load().pipe(onErrorResumeNext()).subscribe(); - - this.assetsFilter.setValue(this.state.snapshot.assetsQuery); } public reload() { this.state.load(true).pipe(onErrorResumeNext()).subscribe(); } - public search() { - this.state.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); + public search(query: string) { + this.state.search(query).pipe(onErrorResumeNext()).subscribe(); } public complete() { diff --git a/src/Squidex/app/features/content/pages/contents/search-form.component.html b/src/Squidex/app/shared/components/search-form.component.html similarity index 79% rename from src/Squidex/app/features/content/pages/contents/search-form.component.html rename to src/Squidex/app/shared/components/search-form.component.html index c8452bd0c..ac031d78d 100644 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.html +++ b/src/Squidex/app/shared/components/search-form.component.html @@ -3,23 +3,27 @@
- + - - - - - - - + + + - - - - - + + + + + + + + + + + + +
@@ -45,7 +49,7 @@
- +
@@ -53,12 +57,12 @@
- +
-
+