diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d652ce0..d9e728246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.10.0 - 2018-08-29 + +### Featues + +* **Contents**: Introduce `X-Unpublished` header to also get unpublished content. +* **UI**: General feature to store UI settings. +* **UI**: Save content queries. +* **UI**: Save assets queries. +* **GraphQL**: Endpoint to run multiple queries in parallel with a single request. + ## v1.9.0 - 2018-08-19 ### Features 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.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/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs new file mode 100644 index 000000000..ea1e17408 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// 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 State state = new State(); + + [CollectionName("UISettings")] + public sealed class State + { + public JObject Settings { get; set; } = 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.Settings.AsJ()); + } + + public Task SetAsync(J settings) + { + state.Settings = settings; + + 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.Settings; + + 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..38fde5c74 --- /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 settings); + + Task RemoveAsync(string path); + } +} 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/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.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..53e15ff75 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 @@ -70,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) { @@ -94,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); @@ -234,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) { @@ -242,11 +260,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/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..dce94972e 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, params 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 { data = result.Data, errors = result.Errors }); + } + else + { + return (false, new { data = 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..693f1fabf 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, params GraphQLQuery[] queries); + + Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query); } } 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); } } 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.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 90ffe3948..294de505e 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/batch")] + [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); } } @@ -482,7 +513,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/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 daa152b9c..0c1d38e01 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -5,12 +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.Core.Rules.Actions; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure.Commands; using Squidex.Pipeline; @@ -19,38 +22,81 @@ namespace Squidex.Areas.Api.Controllers.UI /// /// Manages ui settings and configs. /// + [ApiAuthorize] [ApiExceptionFilter] + [AppApi] [SwaggerTag(nameof(UI))] public sealed class UIController : ApiController { private readonly MyUIOptions uiOptions; - private readonly TwitterOptions twitterOptions; + private readonly IGrainFactory grainFactory; - public UIController(ICommandBus commandBus, IOptions uiOptions, IOptions twitterOptions) + public UIController(ICommandBus commandBus, IOptions uiOptions, IGrainFactory grainFactory) : base(commandBus) { - this.twitterOptions = twitterOptions.Value; - 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 result = await grainFactory.GetGrain(App.Id).GetAsync(); + + 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] UpdateSettingDto request) { - var dto = new UISettingsDto - { - MapType = uiOptions.Map?.Type ?? "OSM", - MapKey = uiOptions.Map?.GoogleMaps?.Key, - SupportsTwitterActions = twitterOptions.IsConfigured() - }; - - return Ok(dto); + await grainFactory.GetGrain(App.Id).SetAsync(key, request.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/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 38718404e..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 @@ - +
+
+ - - + +
+
+ -
- -
+ + +
+
@@ -23,18 +32,18 @@ -
- + - + + diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.scss b/src/Squidex/app/features/assets/pages/assets-page.component.scss index e1b86515d..f353ad360 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.scss +++ b/src/Squidex/app/features/assets/pages/assets-page.component.scss @@ -4,25 +4,4 @@ .section { border-top: 1px solid $color-border; padding: 1rem; -} - -.tag { - & { - padding: .25rem 0; - } - - &.active { - font-weight: bold; - } - - &.active, - &:hover { - background: $color-background; - } -} - -a { - &.tag { - cursor: pointer !important; - } } \ No newline at end of file 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 03440fca8..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 @@ -1,6 +1,6 @@ - + Archive @@ -14,18 +14,21 @@
+ +
- - -
@@ -118,6 +121,31 @@ + + + + + + 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 c5732e2b7..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,10 @@ import { ImmutableArray, LanguagesState, ModalModel, + Queries, SchemaDetailsDto, - SchemasState + SchemasState, + UIState } from '@app/shared'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; @@ -34,6 +36,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit { private selectedSchemaSubscription: Subscription; public schema: SchemaDetailsDto; + public schemaQueries: Queries; public searchModal = new ModalModel(); @@ -55,7 +58,8 @@ export class ContentsPageComponent implements OnDestroy, OnInit { public readonly appsState: AppsState, public readonly contentsState: ContentsState, private readonly languagesState: LanguagesState, - private readonly schemasState: SchemasState + private readonly schemasState: SchemasState, + private readonly uiState: UIState ) { } @@ -72,6 +76,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit { this.resetSelection(); this.schema = schema!; + this.schemaQueries = new Queries(this.uiState, `schemas.${this.schema.name}`); this.contentsState.init().pipe(onErrorResumeNext()).subscribe(); }); @@ -103,38 +108,42 @@ export class ContentsPageComponent implements OnDestroy, OnInit { } public publish(content: ContentDto) { - this.changeContentItems([content], 'Publish', false); + this.changeContentItems([content], 'Publish'); } - public publishSelected(scheduled: boolean) { - this.changeContentItems(this.select(c => c.status !== 'Published'), 'Publish', false); + public publishSelected() { + this.changeContentItems(this.select(c => c.status !== 'Published'), 'Publish'); } public unpublish(content: ContentDto) { - this.changeContentItems([content], 'Unpublish', false); + this.changeContentItems([content], 'Unpublish'); } - public unpublishSelected(scheduled: boolean) { - this.changeContentItems(this.select(c => c.status === 'Published'), 'Unpublish', false); + public unpublishSelected() { + this.changeContentItems(this.select(c => c.status === 'Published'), 'Unpublish'); } public archive(content: ContentDto) { - this.changeContentItems([content], 'Archive', true); + this.changeContentItems([content], 'Archive'); } - public archiveSelected(scheduled: boolean) { - this.changeContentItems(this.select(), 'Archive', true); + public archiveSelected() { + this.changeContentItems(this.select(), 'Archive'); } public restore(content: ContentDto) { - this.changeContentItems([content], 'Restore', true); + this.changeContentItems([content], 'Restore'); } public restoreSelected(scheduled: boolean) { - this.changeContentItems(this.select(), 'Restore', true); + this.changeContentItems(this.select(), 'Restore'); } - private changeContentItems(contents: ContentDto[], action: string, reload: boolean) { + public isSelectedQuery(query: string) { + return query === this.contentsState.snapshot.contentsQuery || (!query && !this.contentsState.contentsQuery); + } + + private changeContentItems(contents: ContentDto[], action: string) { if (contents.length === 0) { return; } diff --git a/src/Squidex/app/features/content/pages/contents/search-form.component.html b/src/Squidex/app/features/content/pages/contents/search-form.component.html deleted file mode 100644 index 0f3fbd2ad..000000000 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - -
- - - - - -
- - - Click this icon to show the advanced search menu and to show the archive! - - - - Search for content using full text search over all fields and languages! - - - \ No newline at end of file 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/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/components/search-form.component.html b/src/Squidex/app/shared/components/search-form.component.html new file mode 100644 index 000000000..e68f73f5e --- /dev/null +++ b/src/Squidex/app/shared/components/search-form.component.html @@ -0,0 +1,99 @@ + + + + +
+ + + + + + + + + Click this icon to show the advanced search menu and to show the archive! + + + + + + + + + + + + + + + + +
+ + + Search for content using full text search over all fields and languages! + + + + + +
+ + + 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/shared/components/search-form.component.scss similarity index 75% rename from src/Squidex/app/features/content/pages/contents/search-form.component.scss rename to src/Squidex/app/shared/components/search-form.component.scss index 20c685cc2..4eb03572e 100644 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.scss +++ b/src/Squidex/app/shared/components/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/shared/components/search-form.component.ts similarity index 73% rename from src/Squidex/app/features/content/pages/contents/search-form.component.ts rename to src/Squidex/app/shared/components/search-form.component.ts index babf2991b..5b76a7de7 100644 --- a/src/Squidex/app/features/content/pages/contents/search-form.component.ts +++ b/src/Squidex/app/shared/components/search-form.component.ts @@ -5,10 +5,17 @@ * 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, + Queries, + SaveQueryForm +} from '@app/shared/internal'; + +import { Observable } from 'rxjs'; +import { shareReplay } from 'rxjs/operators'; @Component({ selector: 'sqx-search-form', @@ -16,7 +23,19 @@ 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: Queries; + + @Input() + public placeholder = ''; + + @Input() + public fieldExample = '[MY_FIELD]'; + + @Input() + public expandable = false; + @Input() public query = ''; @@ -30,7 +49,10 @@ export class SearchFormComponent implements OnChanges { public archivedChanged = new EventEmitter(); @Input() - public canArchive = true; + public schemaName = ''; + + @Input() + public enableArchive = false; @Input() public enableShortcut = false; @@ -39,6 +61,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 +73,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/declarations.ts b/src/Squidex/app/shared/declarations.ts index 63d282884..b0e6b8595 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -18,5 +18,6 @@ export * from './components/markdown-editor.component'; export * from './components/pipes'; export * from './components/rich-editor.component'; export * from './components/schema-category.component'; +export * from './components/search-form.component'; export * from './internal'; \ No newline at end of file diff --git a/src/Squidex/app/shared/internal.ts b/src/Squidex/app/shared/internal.ts index 51fb48eb8..2a3e2ba5a 100644 --- a/src/Squidex/app/shared/internal.ts +++ b/src/Squidex/app/shared/internal.ts @@ -57,10 +57,12 @@ export * from './state/languages.state'; export * from './state/patterns.forms'; export * from './state/patterns.state'; export * from './state/plans.state'; +export * from './state/queries'; 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..49e44f77b 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -66,7 +66,9 @@ import { SchemaMustExistPublishedGuard, SchemasService, SchemasState, + SearchFormComponent, UIService, + UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, @@ -107,7 +109,8 @@ import { UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, - RichEditorComponent + RichEditorComponent, + SearchFormComponent ], exports: [ AppFormComponent, @@ -125,6 +128,7 @@ import { MarkdownEditorComponent, RouterModule, SchemaCategoryComponent, + SearchFormComponent, UserDtoPicture, UserIdPicturePipe, UserNamePipe, @@ -179,6 +183,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..9a8a43c80 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/assets.state.ts b/src/Squidex/app/shared/state/assets.state.ts index 45457abc0..233010500 100644 --- a/src/Squidex/app/shared/state/assets.state.ts +++ b/src/Squidex/app/shared/state/assets.state.ts @@ -45,6 +45,10 @@ export class AssetsState extends State { this.changes.pipe(map(x => x.assets), distinctUntilChanged()); + public assetsQuery = + this.changes.pipe(map(x => x.assetsQuery), + distinctUntilChanged()); + public assetsPager = this.changes.pipe(map(x => x.assetsPager), distinctUntilChanged()); 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/queries.spec.ts b/src/Squidex/app/shared/state/queries.spec.ts new file mode 100644 index 000000000..bac5abc65 --- /dev/null +++ b/src/Squidex/app/shared/state/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 { Queries, Query } from './queries'; +import { UIState } from './ui.state'; + +describe('Queries', () => { + const prefix = 'schemas.my-schema'; + + let uiState: IMock; + let filter = new BehaviorSubject(''); + let queries$ = new BehaviorSubject({}); + let queries: Queries; + + beforeEach(() => { + uiState = Mock.ofType(); + + uiState.setup(x => x.get('schemas.my-schema.queries', {})) + .returns(() => queries$); + + queries$.next({ + key1: 'query1', + key2: 'query2' + }); + + queries = new Queries(uiState.object, prefix); + }); + + it('should load queries', () => { + let converted: Query[]; + + queries.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; + + queries.getSaveKey(filter).subscribe(x => { + key = x!; + }); + + filter.next('query2'); + + expect(key!).toEqual('key2'); + }); + + it('should forward add call to state', () => { + queries.add('key3', 'filter3'); + + uiState.verify(x => x.set('schemas.my-schema.queries.key3', 'filter3'), Times.once()); + }); + + it('should forward remove call to state', () => { + queries.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/queries.ts b/src/Squidex/app/shared/state/queries.ts new file mode 100644 index 000000000..dd48accfa --- /dev/null +++ b/src/Squidex/app/shared/state/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 Queries { + public queries: Observable; + + public defaultQueries: Query[] = [{ + name: 'All (newest first)', filter: '' + }, { + name: 'All (oldest first)', filter: '$orderby=lastModified asc' + }]; + + constructor( + private readonly uiState: UIState, + private readonly prefix: string + ) { + this.queries = this.uiState.get(`${this.prefix}.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(`${this.prefix}.queries.${key}`, filter); + } + + public remove(key: string) { + this.uiState.remove(`${this.prefix}.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.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..84a633a81 --- /dev/null +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -0,0 +1,139 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { distinctUntilChanged, map } 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) { + if (!reset) { + this.resetState(); + } + + this.uiService.getSettings(this.appName) + .subscribe(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/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.html b/src/Squidex/app/theme/icomoon/demo.html index 13ba18134..993a5be49 100644 --- a/src/Squidex/app/theme/icomoon/demo.html +++ b/src/Squidex/app/theme/icomoon/demo.html @@ -9,10 +9,42 @@
-

Font Name: icomoon (Glyphs: 96)

+

Font Name: icomoon (Glyphs: 98)

Grid Size: 16

+
+
+ + + + icon-star-full +
+
+ + +
+
+ liga: + +
+
+
+
+ + + + icon-star-empty +
+
+ + +
+
+ liga: + +
+
@@ -368,246 +400,6 @@
-
-
- - - - icon-action-Algolia -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-type-Tags -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-activity -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-history -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-time -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-add -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-plus -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-check-circle -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-check-circle-filled -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-close -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-type-References -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-control-Checkbox -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-control-Dropdown -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-control-Input -
-
- - -
-
- liga: - -
-
-
-
- - - - icon-control-Radio -
-
- - -
-
- liga: - -
-

Grid Size: Unknown

diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot index 8b2611dd7..d3d284831 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.eot and b/src/Squidex/app/theme/icomoon/fonts/icomoon.eot differ diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg index 1615a37f0..d4bcfdb28 100644 --- a/src/Squidex/app/theme/icomoon/fonts/icomoon.svg +++ b/src/Squidex/app/theme/icomoon/fonts/icomoon.svg @@ -100,6 +100,8 @@ + + diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf index 8e3ee27d3..d3f0a0fa9 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf and b/src/Squidex/app/theme/icomoon/fonts/icomoon.ttf differ diff --git a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff index d827a95c6..e5feeb832 100644 Binary files a/src/Squidex/app/theme/icomoon/fonts/icomoon.woff and b/src/Squidex/app/theme/icomoon/fonts/icomoon.woff differ diff --git a/src/Squidex/app/theme/icomoon/selection.json b/src/Squidex/app/theme/icomoon/selection.json index 4184eab1d..322f15704 100644 --- a/src/Squidex/app/theme/icomoon/selection.json +++ b/src/Squidex/app/theme/icomoon/selection.json @@ -1,6 +1,72 @@ { "IcoMoonType": "selection", "icons": [ + { + "icon": { + "paths": [ + "M1024 397.050l-353.78-51.408-158.22-320.582-158.216 320.582-353.784 51.408 256 249.538-60.432 352.352 316.432-166.358 316.432 166.358-60.434-352.352 256.002-249.538z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "star-full", + "rate", + "star", + "favorite", + "bookmark" + ], + "grid": 16 + }, + "attrs": [ + {} + ], + "properties": { + "order": 1, + "id": 2, + "prevSize": 32, + "code": 59741, + "name": "star-full" + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 0 + }, + { + "icon": { + "paths": [ + "M1024 397.050l-353.78-51.408-158.22-320.582-158.216 320.582-353.784 51.408 256 249.538-60.432 352.352 316.432-166.358 316.432 166.358-60.434-352.352 256.002-249.538zM512 753.498l-223.462 117.48 42.676-248.83-180.786-176.222 249.84-36.304 111.732-226.396 111.736 226.396 249.836 36.304-180.788 176.222 42.678 248.83-223.462-117.48z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "tags": [ + "star-empty", + "rate", + "star", + "favorite", + "bookmark" + ], + "grid": 16 + }, + "attrs": [ + {} + ], + "properties": { + "order": 2, + "id": 1, + "prevSize": 32, + "code": 59742, + "name": "star-empty" + }, + "setIdx": 0, + "setId": 2, + "iconIdx": 1 + }, { "icon": { "paths": [ @@ -31,7 +97,7 @@ }, "setIdx": 0, "setId": 2, - "iconIdx": 0 + "iconIdx": 2 }, { "icon": { diff --git a/src/Squidex/app/theme/icomoon/style.css b/src/Squidex/app/theme/icomoon/style.css index 9a867797a..6a133a1d3 100644 --- a/src/Squidex/app/theme/icomoon/style.css +++ b/src/Squidex/app/theme/icomoon/style.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?f9httd'); - src: url('fonts/icomoon.eot?f9httd#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?f9httd') format('truetype'), - url('fonts/icomoon.woff?f9httd') format('woff'), - url('fonts/icomoon.svg?f9httd#icomoon') format('svg'); + src: url('fonts/icomoon.eot?jk799'); + src: url('fonts/icomoon.eot?jk799#iefix') format('embedded-opentype'), + url('fonts/icomoon.ttf?jk799') format('truetype'), + url('fonts/icomoon.woff?jk799') format('woff'), + url('fonts/icomoon.svg?jk799#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,12 @@ -moz-osx-font-smoothing: grayscale; } +.icon-star-full:before { + content: "\e95d"; +} +.icon-star-empty:before { + content: "\e95e"; +} .icon-twitter:before { content: "\e95c"; } @@ -90,51 +96,6 @@ .icon-download:before { content: "\e93e"; } -.icon-action-Algolia:before { - content: "\e94c"; -} -.icon-type-Tags:before { - content: "\e94a"; -} -.icon-activity:before { - content: "\e904"; -} -.icon-history:before { - content: "\e904"; -} -.icon-time:before { - content: "\e904"; -} -.icon-add:before { - content: "\e905"; -} -.icon-plus:before { - content: "\e905"; -} -.icon-check-circle:before { - content: "\e906"; -} -.icon-check-circle-filled:before { - content: "\e907"; -} -.icon-close:before { - content: "\e908"; -} -.icon-type-References:before { - content: "\e909"; -} -.icon-control-Checkbox:before { - content: "\e90a"; -} -.icon-control-Dropdown:before { - content: "\e90b"; -} -.icon-control-Input:before { - content: "\e90c"; -} -.icon-control-Radio:before { - content: "\e90d"; -} .icon-action-Medium:before { content: "\e959"; } diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 723f5ce35..86351c2a6 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 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/Apps/AppUISettingsGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs new file mode 100644 index 000000000..1e5a0b3e5 --- /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/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs index c772d4aaf..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); @@ -165,16 +169,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 +197,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 +232,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 +259,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); 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..0d152bbbc 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -174,17 +174,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(); + } } } 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(); }