diff --git a/README.md b/README.md index bde3d43fd..2deca233c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ 3![Squidex Logo](https://raw.githubusercontent.com/Squidex/squidex/master/media/logo-wide.png "Squidex") -# What is Squidex? +# What is Squidex?? Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We build it with ASP.NET Core and CQRS and is tested for Windows and Linux on modern browsers. diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs new file mode 100644 index 000000000..e9182b39d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettings : IAppUISettings + { + private readonly IGrainFactory grainFactory; + + public AppUISettings(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task GetAsync(Guid appId, string userId) + { + var result = await GetGrain(appId, userId).GetAsync(); + + return result.Value; + } + + public Task RemoveAsync(Guid appId, string userId, string path) + { + return GetGrain(appId, userId).RemoveAsync(path); + } + + public Task SetAsync(Guid appId, string userId, string path, IJsonValue value) + { + return GetGrain(appId, userId).SetAsync(path, value.AsJ()); + } + + public Task SetAsync(Guid appId, string userId, JsonObject settings) + { + return GetGrain(appId, userId).SetAsync(settings.AsJ()); + } + + private IAppUISettingsGrain GetGrain(Guid appId, string userId) + { + return grainFactory.GetGrain(Key(appId, userId)); + } + + private string Key(Guid appId, string userId) + { + if (!string.IsNullOrWhiteSpace(userId)) + { + return $"{appId}_{userId}"; + } + else + { + return $"{appId}"; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs index b6b01582a..519afc570 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -15,7 +15,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Apps { - public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain + public sealed class AppUISettingsGrain : GrainOfString, IAppUISettingsGrain { private readonly IGrainState state; @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public Task SetAsync(string path, J value) { - var container = GetContainer(path, out var key); + var container = GetContainer(path, true, out var key); if (container == null) { @@ -58,19 +58,19 @@ namespace Squidex.Domain.Apps.Entities.Apps return state.WriteAsync(); } - public Task RemoveAsync(string path) + public async Task RemoveAsync(string path) { - var container = GetContainer(path, out var key); + var container = GetContainer(path, false, out var key); - if (container != null) + if (container?.ContainsKey(key) == true) { container.Remove(key); - } - return state.WriteAsync(); + await state.WriteAsync(); + } } - private JsonObject GetContainer(string path, out string key) + private JsonObject GetContainer(string path, bool add, out string key) { Guard.NotNullOrEmpty(path, nameof(path)); @@ -86,9 +86,16 @@ namespace Squidex.Domain.Apps.Entities.Apps { if (!current.TryGetValue(segment, out var temp)) { - temp = JsonValue.Object(); - - current[segment] = temp; + if (add) + { + temp = JsonValue.Object(); + + current[segment] = temp; + } + else + { + return null; + } } if (temp is JsonObject next) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs index 2cb2c5236..04cfe633b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -24,9 +24,9 @@ namespace Squidex.Domain.Apps.Entities.Apps { private const string UsersFile = "Users.json"; private const string SettingsFile = "Settings.json"; - private readonly IGrainFactory grainFactory; + private readonly IAppUISettings appUISettings; + private readonly IAppsIndex appsIndex; private readonly IUserResolver userResolver; - private readonly IAppsIndex grainAppIndex; private readonly HashSet contributors = new HashSet(); private readonly Dictionary userMapping = new Dictionary(); private Dictionary usersWithEmail = new Dictionary(); @@ -35,14 +35,14 @@ namespace Squidex.Domain.Apps.Entities.Apps public override string Name { get; } = "Apps"; - public BackupApps(IGrainFactory grainFactory, IUserResolver userResolver, IAppsIndex grainAppIndex) + public BackupApps(IAppUISettings appUISettings, IAppsIndex appsIndex, IUserResolver userResolver) { - Guard.NotNull(grainAppIndex, nameof(grainAppIndex)); - Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(appsIndex, nameof(appsIndex)); Guard.NotNull(userResolver, nameof(userResolver)); + Guard.NotNull(appUISettings, nameof(appUISettings)); - this.grainAppIndex = grainAppIndex; - this.grainFactory = grainFactory; + this.appsIndex = appsIndex; + this.appUISettings = appUISettings; this.userResolver = userResolver; } @@ -124,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Apps private async Task ReserveAppAsync(Guid appId) { - appReservation = await grainAppIndex.ReserveAsync(appId, appName); + appReservation = await appsIndex.ReserveAsync(appId, appName); if (appReservation == null) { @@ -134,7 +134,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public override async Task CleanupRestoreErrorAsync(Guid appId) { - await grainAppIndex.RemoveReservationAsync(appReservation); + await appsIndex.RemoveReservationAsync(appReservation); } private RefToken MapUser(string userId, RefToken fallback) @@ -180,7 +180,7 @@ namespace Squidex.Domain.Apps.Entities.Apps private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) { - var json = await grainFactory.GetGrain(appId).GetAsync(); + var json = await appUISettings.GetAsync(appId, null); await writer.WriteJsonAsync(SettingsFile, json); } @@ -189,14 +189,14 @@ namespace Squidex.Domain.Apps.Entities.Apps { var json = await reader.ReadJsonAttachmentAsync(SettingsFile); - await grainFactory.GetGrain(appId).SetAsync(json); + await appUISettings.SetAsync(appId, null, json); } public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) { - await grainAppIndex.AddAsync(appReservation); + await appsIndex.AddAsync(appReservation); - await grainAppIndex.RebuildByContributorsAsync(appId, contributors); + await appsIndex.RebuildByContributorsAsync(appId, contributors); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs new file mode 100644 index 000000000..d9e0f8d45 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppUISettings + { + Task GetAsync(Guid appId, string userId); + + Task SetAsync(Guid appId, string userId, string path, IJsonValue value); + + Task SetAsync(Guid appId, string userId, JsonObject settings); + + Task RemoveAsync(Guid appId, string userId, string path); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs index 69583ff25..4ce221196 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -12,7 +12,7 @@ using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Apps { - public interface IAppUISettingsGrain : IGrainWithGuidKey + public interface IAppUISettingsGrain : IGrainWithStringKey { Task> GetAsync(); diff --git a/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs index e50cc05f4..a90a2dc01 100644 --- a/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs @@ -21,7 +21,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Comments { - public sealed class CommentsGrain : DomainObjectGrainBase, ICommentGrain + public sealed class CommentsGrain : DomainObjectGrainBase, ICommentsGrain { private readonly IStore store; private readonly List> events = new List>(); diff --git a/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs b/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs new file mode 100644 index 000000000..8380499ad --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class CommentsLoader : ICommentsLoader + { + private readonly IGrainFactory grainFactory; + + public CommentsLoader(IGrainFactory grainFactory) + { + this.grainFactory = grainFactory; + } + + public Task GetCommentsAsync(Guid id, long version = EtagVersion.Any) + { + var grain = grainFactory.GetGrain(id); + + return grain.GetCommentsAsync(version); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs b/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs similarity index 91% rename from src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs rename to src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs index d3cd6979a..41ec3bc4f 100644 --- a/src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Comments { - public interface ICommentGrain : IDomainObjectGrain + public interface ICommentsGrain : IDomainObjectGrain { Task GetCommentsAsync(long version = EtagVersion.Any); } diff --git a/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs b/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs new file mode 100644 index 000000000..bc85962a5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public interface ICommentsLoader + { + Task GetCommentsAsync(Guid id, long version = EtagVersion.Any); + } +} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index 8cc6bfd7d..36a5e8472 100644 --- a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -9,7 +9,6 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -using Orleans; using Squidex.Areas.Api.Controllers.Comments.Models; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; @@ -26,12 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Comments [ApiExplorerSettings(GroupName = nameof(Comments))] public sealed class CommentsController : ApiController { - private readonly IGrainFactory grainFactory; + private readonly ICommentsLoader commentsLoader; - public CommentsController(ICommandBus commandBus, IGrainFactory grainFactory) + public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader) : base(commandBus) { - this.grainFactory = grainFactory; + this.commentsLoader = commentsLoader; } /// @@ -54,7 +53,7 @@ namespace Squidex.Areas.Api.Controllers.Comments [ApiCosts(0)] public async Task GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any) { - var result = await grainFactory.GetGrain(commentsId).GetCommentsAsync(version); + var result = await commentsLoader.GetCommentsAsync(commentsId, version); var response = Deferred.Response(() => { diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index dd12c5be5..7257f70f6 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -9,11 +9,10 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Orleans; using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -24,14 +23,14 @@ namespace Squidex.Areas.Api.Controllers.UI { private static readonly Permission CreateAppPermission = new Permission(Permissions.AdminAppCreate); private readonly MyUIOptions uiOptions; - private readonly IGrainFactory grainFactory; + private readonly IAppUISettings appUISettings; - public UIController(ICommandBus commandBus, IOptions uiOptions, IGrainFactory grainFactory) + public UIController(ICommandBus commandBus, IOptions uiOptions, IAppUISettings appUISettings) : base(commandBus) { this.uiOptions = uiOptions.Value; - this.grainFactory = grainFactory; + this.appUISettings = appUISettings; } /// @@ -68,9 +67,28 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task GetSettings(string app) { - var result = await grainFactory.GetGrain(AppId).GetAsync(); + var result = await appUISettings.GetAsync(AppId, null); - return Ok(result.Value); + return Ok(result); + } + + /// + /// Get my ui settings. + /// + /// The name of the app. + /// + /// 200 => UI settings returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/ui/settings/me")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiPermission] + public async Task GetUserSettings(string app) + { + var result = await appUISettings.GetAsync(AppId, UserId()); + + return Ok(result); } /// @@ -88,7 +106,27 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task PutSetting(string app, string key, [FromBody] UpdateSettingDto request) { - await grainFactory.GetGrain(AppId).SetAsync(key, request.Value.AsJ()); + await appUISettings.SetAsync(AppId, null, key, request.Value); + + return NoContent(); + } + + /// + /// Set my ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// The request with the value to update. + /// + /// 200 => UI setting set. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/ui/settings/me/{key}")] + [ApiPermission] + public async Task PutUserSetting(string app, string key, [FromBody] UpdateSettingDto request) + { + await appUISettings.SetAsync(AppId, UserId(), key, request.Value); return NoContent(); } @@ -107,9 +145,40 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task DeleteSetting(string app, string key) { - await grainFactory.GetGrain(AppId).RemoveAsync(key); + await appUISettings.RemoveAsync(AppId, null, key); + + return NoContent(); + } + + /// + /// Remove my 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/me/{key}")] + [ApiPermission] + public async Task DeleteUserSetting(string app, string key) + { + await appUISettings.RemoveAsync(AppId, UserId(), key); return NoContent(); } + + private string UserId() + { + var subject = User.OpenIdSubject(); + + if (string.IsNullOrWhiteSpace(subject)) + { + throw new DomainForbiddenException("Not allowed for clients."); + } + + return subject; + } } } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 13f2b9ca4..628977f62 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -93,9 +93,15 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -257,7 +263,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs>() + services.AddSingletonAs>() .As(); services.AddSingletonAs>() diff --git a/src/Squidex/app/features/assets/pages/assets-filters-page.component.html b/src/Squidex/app/features/assets/pages/assets-filters-page.component.html index 8c18d686f..f3782afbe 100644 --- a/src/Squidex/app/features/assets/pages/assets-filters-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-filters-page.component.html @@ -28,28 +28,10 @@
- + + \ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts b/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts index 9fcc66ab2..58991e740 100644 --- a/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts +++ b/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts @@ -21,7 +21,7 @@ import { templateUrl: './assets-filters-page.component.html' }) export class AssetsFiltersPageComponent { - public queries = new Queries(this.uiState, 'assets'); + public assetsQueries = new Queries(this.uiState, 'assets'); constructor( public readonly assetsState: AssetsState, @@ -29,6 +29,10 @@ export class AssetsFiltersPageComponent { ) { } + public isQueryUsed = (query: SavedQuery) => { + return this.assetsState.isQueryUsed(query); + } + public search(query: Query) { this.assetsState.search(query); } @@ -45,15 +49,7 @@ export class AssetsFiltersPageComponent { this.assetsState.resetTags(); } - public isSelectedQuery(saved: SavedQuery) { - return this.assetsState.isQueryUsed(saved); - } - - public trackByTag(index: number, tag: { name: string }) { + public trackByTag(tag: { name: string }) { return tag.name; } - - public trackByQuery(index: number, query: { name: string }) { - return query.name; - } } \ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 1a56a578a..81a5d4e06 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -100,7 +100,7 @@ Viewing version {{version.value}}. - + ; + public trackByFieldFn: Function; + @ViewChild('dueTimeSelector', { static: false }) public dueTimeSelector: DueTimeSelectorComponent; @@ -80,6 +82,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD ) { super(); + this.trackByFieldFn = this.trackByField.bind(this); + this.formContext = { user: authService.user, apiUrl: apiUrl.buildUrl('api') }; } diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html index cc0943fa2..3cf873ff7 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html @@ -4,8 +4,8 @@ - + {{default.name}} @@ -15,7 +15,7 @@

Status Queries

+ [class.active]="isQueryUsed(status)"> {{status.name}} @@ -23,28 +23,10 @@
- + +
\ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts index 4f4726d30..82e4969bb 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts @@ -43,16 +43,12 @@ export class ContentsFiltersPageComponent extends ResourceOwner implements OnIni })); } - public search(query: Query) { - this.contentsState.search(query); - } - - public isSelectedQuery(saved: SavedQuery) { - return this.contentsState.isQueryUsed(saved); + public isQueryUsed = (query: SavedQuery) => { + return this.contentsState.isQueryUsed(query); } - public trackByTag(index: number, tag: { name: string }) { - return tag.name; + public search(query: Query) { + this.contentsState.search(query); } public trackByQuery(index: number, query: { name: string }) { diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index 6a82009ef..f5ed992d7 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -57,7 +57,7 @@ - + diff --git a/src/Squidex/app/features/content/shared/content-item.component.ts b/src/Squidex/app/features/content/shared/content-item.component.ts index 4dd5e0182..f5f195645 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.ts +++ b/src/Squidex/app/features/content/shared/content-item.component.ts @@ -74,6 +74,8 @@ export class ContentItemComponent implements OnChanges { @Input('sqxContent') public content: ContentDto; + public trackByFieldFn: Function; + public patchForm: PatchContentForm; public patchAllowed = false; @@ -89,6 +91,7 @@ export class ContentItemComponent implements OnChanges { private readonly changeDetector: ChangeDetectorRef, private readonly contentsState: ContentsState ) { + this.trackByFieldFn = this.trackByField.bind(this); } public ngOnChanges(changes: SimpleChanges) { diff --git a/src/Squidex/app/features/content/shared/due-time-selector.component.html b/src/Squidex/app/features/content/shared/due-time-selector.component.html index 08d41205e..ad6bcb08d 100644 --- a/src/Squidex/app/features/content/shared/due-time-selector.component.html +++ b/src/Squidex/app/features/content/shared/due-time-selector.component.html @@ -6,14 +6,14 @@
- +
- + diff --git a/src/Squidex/app/features/schemas/pages/schema/field.component.html b/src/Squidex/app/features/schemas/pages/schema/field.component.html index 2abde3801..921d3d8bc 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/field.component.html @@ -98,7 +98,7 @@ [sqxSortDisabled]="!isEditable" [sqxSortModel]="nested" (sqxSort)="sortFields($event)"> -
+
diff --git a/src/Squidex/app/features/schemas/pages/schema/field.component.ts b/src/Squidex/app/features/schemas/pages/schema/field.component.ts index e64adf94e..d223ab8ed 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/field.component.ts @@ -46,6 +46,8 @@ export class FieldComponent implements OnChanges { public dropdown = new ModalModel(); + public trackByFieldFn: Function; + public isEditing = false; public isEditable = false; @@ -58,6 +60,7 @@ export class FieldComponent implements OnChanges { private readonly formBuilder: FormBuilder, private readonly schemasState: SchemasState ) { + this.trackByFieldFn = this.trackByField.bind(this); } public ngOnChanges(changes: SimpleChanges) { diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html index d55b9e6f6..c2a72eea0 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html @@ -79,7 +79,7 @@ [sqxSortDisabled]="!schema.canOrderFields" [sqxSortModel]="schema.fields" (sqxSort)="sortFields($event)"> -
+
diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts index acd0faf18..250aedc9d 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts @@ -48,6 +48,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { public editSchemaDialog = new DialogModel(); public exportDialog = new DialogModel(); + public trackByFieldFn: Function; + constructor( public readonly appsState: AppsState, public readonly schemasState: SchemasState, @@ -57,6 +59,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { private readonly messageBus: MessageBus ) { super(); + + this.trackByFieldFn = this.trackByField.bind(this); } public ngOnInit() { diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html index e61b3ff27..eaee544c2 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html @@ -28,6 +28,39 @@ + +
+ + Just enter the email address to invite someone with no account to the app. + + +
+
+
+ + + + + + {{user.displayName}} + + + +
+
+ +
+
+
+
+ +
+ + Big team? Add many contributors at once + +
+
+ @@ -56,7 +89,7 @@
- +
@@ -64,37 +97,6 @@ No contributors found.
- - - - - - Big team? Add many contributors at once - - diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss index e42bfaa5c..56afd85b9 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss @@ -9,4 +9,14 @@ &-name { margin-left: .25rem; } +} + +.import-hint { + margin-bottom: 1rem; + font-weight: normal; + font-size: 90%; +} + +.table-items-header { + margin: 0; } \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/forms/form-alert.component.ts b/src/Squidex/app/framework/angular/forms/form-alert.component.ts index 052fca509..d9161242b 100644 --- a/src/Squidex/app/framework/angular/forms/form-alert.component.ts +++ b/src/Squidex/app/framework/angular/forms/form-alert.component.ts @@ -16,12 +16,12 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; max-width: 100%; } - .white { - background: #fff; + .light { + background: #fcfeff; } `], template: ` -
+
`, changeDetection: ChangeDetectionStrategy.OnPush @@ -37,5 +37,5 @@ export class FormAlertComponent { public marginBottom = 4; @Input() - public white = false; + public light = false; } \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/types.spec.ts b/src/Squidex/app/framework/utils/types.spec.ts index 68cbb4613..eeeef1153 100644 --- a/src/Squidex/app/framework/utils/types.spec.ts +++ b/src/Squidex/app/framework/utils/types.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Types } from './types'; +import { mergeInto, Types } from './types'; describe('Types', () => { it('should calculate hash string', () => { @@ -152,6 +152,39 @@ describe('Types', () => { it('should treat object of empty values as empty', () => { expect(Types.isEmpty({ a: null, b: null })).toBeTruthy(); }); + + it('should merge deeply', () => { + const source = {}; + + mergeInto(source, { + rootShared: 1, + rootA: 2, + nested: { + a: 3 + }, + array: [4] + }); + + mergeInto(source, { + rootShared: 5, + rootB: 6, + nested: { + b: 7 + }, + array: [8] + }); + + expect(source).toEqual({ + rootShared: 5, + rootA: 2, + rootB: 6, + nested: { + a: 3, + b: 7 + }, + array: [4, 8] + }); + }); }); class MyClass { diff --git a/src/Squidex/app/framework/utils/types.ts b/src/Squidex/app/framework/utils/types.ts index 22841eb4f..2a6dcde38 100644 --- a/src/Squidex/app/framework/utils/types.ts +++ b/src/Squidex/app/framework/utils/types.ts @@ -127,4 +127,25 @@ export module Types { return Types.isUndefined(value) === true || Types.isNull(value) === true; } +} + +export function mergeInto(target: object, source: object) { + if (!Types.isObject(target) || !Types.isObject(source)) { + return source; + } + + Object.keys(source).forEach(key => { + const targetValue = target[key]; + const sourceValue = source[key]; + + if (Types.isArray(targetValue) && Types.isArray(sourceValue)) { + target[key] = targetValue.concat(sourceValue); + } else if (Types.isObject(targetValue) && Types.isObject(sourceValue)) { + target[key] = mergeInto({ ...targetValue }, sourceValue); + } else { + target[key] = sourceValue; + } + }); + + return target; } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/saved-queries.component.ts b/src/Squidex/app/shared/components/saved-queries.component.ts new file mode 100644 index 000000000..d9d09a296 --- /dev/null +++ b/src/Squidex/app/shared/components/saved-queries.component.ts @@ -0,0 +1,87 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { Queries } from '@app/shared/internal'; +import { SavedQuery } from '../state/queries'; + +@Component({ + selector: 'sqx-shared-queries', + template: ` + + +
+ + + ` +}) +export class SavedQueriesComponent { + @Input() + public queryUsed: (saved: SavedQuery) => boolean; + + @Input() + public queries: Queries; + + @Input() + public types: string; + + @Output() + public search = new EventEmitter(); + + public isSelectedQuery(saved: SavedQuery) { + return this.queryUsed && this.queryUsed(saved); + } + + public trackByQuery(index: number, query: { name: string }) { + return query.name; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/components/search-form.component.html b/src/Squidex/app/shared/components/search-form.component.html index d1b467070..cdd552fc6 100644 --- a/src/Squidex/app/shared/components/search-form.component.html +++ b/src/Squidex/app/shared/components/search-form.component.html @@ -78,6 +78,13 @@
+ +
+ + +
diff --git a/src/Squidex/app/shared/components/search-form.component.ts b/src/Squidex/app/shared/components/search-form.component.ts index 5baa77436..5089dce47 100644 --- a/src/Squidex/app/shared/components/search-form.component.ts +++ b/src/Squidex/app/shared/components/search-form.component.ts @@ -93,7 +93,7 @@ export class SearchFormComponent implements OnChanges { if (value) { if (this.queries && this.query) { - this.queries.add(value.name, this.query); + this.queries.add(value.name, this.query, value.user); } this.saveQueryForm.submitCompleted(); diff --git a/src/Squidex/app/shared/declarations.ts b/src/Squidex/app/shared/declarations.ts index 722dbe3eb..40116db0c 100644 --- a/src/Squidex/app/shared/declarations.ts +++ b/src/Squidex/app/shared/declarations.ts @@ -24,6 +24,7 @@ export * from './components/pipes'; export * from './components/references-dropdown.component'; export * from './components/rich-editor.component'; export * from './components/schema-category.component'; +export * from './components/saved-queries.component'; export * from './components/search-form.component'; export * from './components/table-header.component'; diff --git a/src/Squidex/app/shared/module.ts b/src/Squidex/app/shared/module.ts index 4e23fc2ee..ef19639e2 100644 --- a/src/Squidex/app/shared/module.ts +++ b/src/Squidex/app/shared/module.ts @@ -78,6 +78,7 @@ import { RuleEventsState, RulesService, RulesState, + SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, @@ -136,6 +137,7 @@ import { MarkdownEditorComponent, QueryComponent, ReferencesDropdownComponent, + SavedQueriesComponent, SchemaCategoryComponent, SortingComponent, UserDtoPicture, @@ -171,6 +173,7 @@ import { ReferencesDropdownComponent, RichEditorComponent, RouterModule, + SavedQueriesComponent, SchemaCategoryComponent, SearchFormComponent, UserDtoPicture, diff --git a/src/Squidex/app/shared/services/ui.service.spec.ts b/src/Squidex/app/shared/services/ui.service.spec.ts index 5a5f9236d..a6029c94a 100644 --- a/src/Squidex/app/shared/services/ui.service.spec.ts +++ b/src/Squidex/app/shared/services/ui.service.spec.ts @@ -71,12 +71,12 @@ describe('UIService', () => { expect(settings!).toEqual({ mapType: 'OSM', mapKey: '', canCreateApps: true }); })); - it('should make get request to get settings', + it('should make get request to get shared settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { let settings: object; - uiService.getSettings('my-app').subscribe(result => { + uiService.getSharedSettings('my-app').subscribe(result => { settings = result; }); @@ -92,12 +92,12 @@ describe('UIService', () => { expect(settings!).toEqual(response); })); - it('should return default settings when error occurs', + it('should return default shared settings when error occurs', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { let settings: object; - uiService.getSettings('my-app').subscribe(result => { + uiService.getSharedSettings('my-app').subscribe(result => { settings = result; }); @@ -111,10 +111,50 @@ describe('UIService', () => { expect(settings!).toBeDefined(); })); - it('should make put request to set value', + it('should make get request to get user settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - uiService.putSetting('my-app', 'root.nested', 123).subscribe(); + let settings: object; + + uiService.getUserSettings('my-app').subscribe(result => { + settings = result; + }); + + const response = { mapType: 'OSM', mapKey: '' }; + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush(response); + + expect(settings!).toEqual(response); + })); + + it('should return default user settings when error occurs', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + let settings: object; + + uiService.getUserSettings('my-app').subscribe(result => { + settings = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.error(new ErrorEvent('500')); + + expect(settings!).toBeDefined(); + })); + + it('should make put request to set shared value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.putSharedSetting('my-app', 'root.nested', 123).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); @@ -122,14 +162,36 @@ describe('UIService', () => { expect(req.request.headers.get('If-Match')).toBeNull(); })); - it('should make delete request to remove value', + it('should make put request to set user value', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - uiService.deleteSetting('my-app', 'root.nested').subscribe(); + uiService.putUserSetting('my-app', 'root.nested', 123).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/root.nested'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); + + it('should make delete request to remove shared value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteSharedSetting('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(); })); + + it('should make delete request to remove user value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteUserSetting('my-app', 'root.nested').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/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 285ace324..f620af4a2 100644 --- a/src/Squidex/app/shared/services/ui.service.ts +++ b/src/Squidex/app/shared/services/ui.service.ts @@ -33,7 +33,7 @@ export class UIService { })); } - public getSettings(appName: string): Observable { + public getSharedSettings(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`); return this.http.get(url).pipe( @@ -42,15 +42,36 @@ export class UIService { })); } - public putSetting(appName: string, key: string, value: any): Observable { + public getUserSettings(appName: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me`); + + return this.http.get(url).pipe( + catchError(() => { + return of({}); + })); + } + + public putSharedSetting(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 { + public putUserSetting(appName: string, key: string, value: any): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me/${key}`); + + return this.http.put(url, { value }); + } + + public deleteSharedSetting(appName: string, key: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); return this.http.delete(url); } + + public deleteUserSetting(appName: string, key: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me/${key}`); + + return this.http.delete(url); + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index 4fa104e40..3a2154cdd 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -53,7 +53,8 @@ export class SaveQueryForm extends Form { [ Validators.required ] - ] + ], + user: false })); } } diff --git a/src/Squidex/app/shared/state/queries.spec.ts b/src/Squidex/app/shared/state/queries.spec.ts index b197dfeaa..b20d466c5 100644 --- a/src/Squidex/app/shared/state/queries.spec.ts +++ b/src/Squidex/app/shared/state/queries.spec.ts @@ -21,25 +21,38 @@ describe('Queries', () => { let uiState: IMock; - let queries$ = new BehaviorSubject({}); let queries: Queries; beforeEach(() => { uiState = Mock.ofType(); - uiState.setup(x => x.get('schemas.my-schema.queries', {})) - .returns(() => queries$); + const shared$ = new BehaviorSubject({ + key1: '{ "fullText": "shared1" }' + }); - queries$.next({ - key1: '{ "fullText": "text1" }', - key2: 'text2', + const user$ = new BehaviorSubject({ + key1: '{ "fullText": "user1" }' + }); + + const merged$ = new BehaviorSubject({ + key1: '{ "fullText": "merged1" }', + key2: 'merged2', key3: undefined }); + uiState.setup(x => x.get('schemas.my-schema.queries', {})) + .returns(() => merged$); + + uiState.setup(x => x.getShared('schemas.my-schema.queries', {})) + .returns(() => shared$); + + uiState.setup(x => x.getUser('schemas.my-schema.queries', {})) + .returns(() => user$); + queries = new Queries(uiState.object, prefix); }); - it('should load queries', () => { + it('should load merged queries', () => { let converted: SavedQuery[]; queries.queries.subscribe(x => { @@ -49,12 +62,12 @@ describe('Queries', () => { expect(converted!).toEqual([ { name: 'key1', - query: { fullText: 'text1' }, - queryJson: encodeQuery({ fullText: 'text1' }) + query: { fullText: 'merged1' }, + queryJson: encodeQuery({ fullText: 'merged1' }) }, { name: 'key2', - query: { fullText: 'text2' }, - queryJson: encodeQuery({ fullText: 'text2' }) + query: { fullText: 'merged2' }, + queryJson: encodeQuery({ fullText: 'merged2' }) }, { name: 'key3', query: undefined, @@ -63,6 +76,38 @@ describe('Queries', () => { ]); }); + it('should load shared queries', () => { + let converted: SavedQuery[]; + + queries.queriesShared.subscribe(x => { + converted = x; + }); + + expect(converted!).toEqual([ + { + name: 'key1', + query: { fullText: 'shared1' }, + queryJson: encodeQuery({ fullText: 'shared1' }) + } + ]); + }); + + it('should load user queries', () => { + let converted: SavedQuery[]; + + queries.queriesUser.subscribe(x => { + converted = x; + }); + + expect(converted!).toEqual([ + { + name: 'key1', + query: { fullText: 'user1' }, + queryJson: encodeQuery({ fullText: 'user1' }) + } + ]); + }); + it('should provide key', () => { let key: string; @@ -74,18 +119,26 @@ describe('Queries', () => { }); it('should forward add call to state', () => { - queries.add('key3', { fullText: 'text3' }); + queries.add('key3', { fullText: 'text3' }, true); + + expect(true).toBeTruthy(); + + uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}', true), Times.once()); + }); + + it('should forward remove shared call to state', () => { + queries.removeShared({ name: 'key3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once()); + uiState.verify(x => x.removeShared('schemas.my-schema.queries.key3'), Times.once()); }); - it('should forward remove call to state', () => { - queries.remove({ name: 'key3' }); + it('should forward remove user call to state', () => { + queries.removeUser({ name: 'key3' }); expect(true).toBeTruthy(); - uiState.verify(x => x.remove('schemas.my-schema.queries.key3'), Times.once()); + uiState.verify(x => x.removeUser('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 index 888c9d316..0f280cccc 100644 --- a/src/Squidex/app/shared/state/queries.ts +++ b/src/Squidex/app/shared/state/queries.ts @@ -6,7 +6,7 @@ */ import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, shareReplay } from 'rxjs/operators'; import { compareStringsAsc, Types } from '@app/framework'; @@ -33,6 +33,8 @@ const OLDEST_FIRST: Query = { export class Queries { public queries: Observable; + public queriesShared: Observable; + public queriesUser: Observable; public defaultQueries: SavedQuery[] = [ { name: 'All (newest first)', queryJson: '' }, @@ -43,21 +45,36 @@ export class Queries { private readonly uiState: UIState, private readonly prefix: string ) { - this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe( - map(settings => { - let queries = Object.keys(settings).map(name => parseStored(name, settings[name])); + const path = `${prefix}.queries`; - return queries.sort((a, b) => compareStringsAsc(a.name, b.name)); - }) - ); + this.queries = this.uiState.get(path, {}).pipe( + map(settings => parseQueries(settings)), shareReplay(1)); + + this.queriesShared = this.uiState.getShared(path, {}).pipe( + map(settings => parseQueries(settings)), shareReplay(1)); + + this.queriesUser = this.uiState.getUser(path, {}).pipe( + map(settings => parseQueries(settings)), shareReplay(1)); + } + + public add(key: string, query: Query, user = false) { + this.uiState.set(this.getPath(key), JSON.stringify(query), user); + } + + public removeShared(saved: SavedQuery) { + this.uiState.removeShared(this.getPath(saved.name)); } - public add(key: string, query: Query) { - this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query)); + public removeUser(saved: SavedQuery) { + this.uiState.removeUser(this.getPath(saved.name)); } public remove(saved: SavedQuery) { - this.uiState.remove(`${this.prefix}.queries.${saved.name}`); + this.uiState.remove(this.getPath(saved.name)); + } + + private getPath(key: string): string { + return `${this.prefix}.queries.${key}`; } public getSaveKey(query: Query): Observable { @@ -76,6 +93,12 @@ export class Queries { } } +function parseQueries(settings: {}) { + let queries = Object.keys(settings).map(name => parseStored(name, settings[name])); + + return queries.sort((a, b) => compareStringsAsc(a.name, b.name)); +} + export function parseStored(name: string, raw?: string) { if (Types.isString(raw)) { let query: Query; diff --git a/src/Squidex/app/shared/state/ui.state.spec.ts b/src/Squidex/app/shared/state/ui.state.spec.ts index 0e7e75de3..a2bd1423d 100644 --- a/src/Squidex/app/shared/state/ui.state.spec.ts +++ b/src/Squidex/app/shared/state/ui.state.spec.ts @@ -6,7 +6,7 @@ */ import { of } from 'rxjs'; -import { IMock, It, Mock, Times } from 'typemoq'; +import { IMock, Mock } from 'typemoq'; import { ResourceLinks, @@ -24,18 +24,30 @@ describe('UIState', () => { appsState } = TestValues; - const appSettings = { - mapType: 'GM', - mapSize: 1024, + const common = { + key: 'xx', + map: { + type: 'GSM', + sizeX: 800, + sizeY: 600 + }, canCreateApps: true }; - const commonSettings = { - mapType: 'OSM', - mapKey: 'Key', + const shared = { + map: { + type: 'GM', key: 'xyz' + }, canCreateApps: true }; + const user = { + map: { + sizeX: 1000 + }, + canCustomize: true + }; + const resources: ResourceLinks = { ['admin/events']: { method: 'GET', href: '/api/events' }, ['admin/restore']: { method: 'GET', href: '/api/restore' }, @@ -49,17 +61,14 @@ describe('UIState', () => { beforeEach(() => { uiService = Mock.ofType(); - uiService.setup(x => x.getSettings(app)) - .returns(() => of(appSettings)); - uiService.setup(x => x.getCommonSettings()) - .returns(() => of(commonSettings)); + .returns(() => of(common)); - uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) - .returns(() => of({})); + uiService.setup(x => x.getSharedSettings(app)) + .returns(() => of(shared)); - uiService.setup(x => x.deleteSetting(app, It.isAnyString())) - .returns(() => of({})); + uiService.setup(x => x.getUserSettings(app)) + .returns(() => of(user)); usersService = Mock.ofType(); @@ -71,10 +80,15 @@ describe('UIState', () => { it('should load settings', () => { expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, - canCreateApps: true + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true }); expect(uiState.snapshot.canReadEvents).toBeTruthy(); @@ -82,17 +96,25 @@ describe('UIState', () => { expect(uiState.snapshot.canRestore).toBeTruthy(); }); - it('should add value to snapshot when set', () => { + it('should add value to snapshot when set as shared', () => { + uiService.setup(x => x.putSharedSetting(app, 'root.nested', 123)) + .returns(() => of({})).verifiable(); + uiState.set('root.nested', 123); expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true, root: { nested: 123 - }, - canCreateApps: true + } }); uiState.get('root', {}).subscribe(x => { @@ -107,36 +129,83 @@ describe('UIState', () => { expect(x).toEqual(1337); }); - uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); + uiService.verifyAll(); }); - it('should remove value from snapshot when removed', () => { - uiState.set('root.nested1', 123); - uiState.set('root.nested2', 123); - uiState.remove('root.nested1'); + it('should add value to snapshot when set as user', () => { + uiService.setup(x => x.putUserSetting(app, 'root.nested', 123)) + .returns(() => of({})).verifiable(); + + uiState.set('root.nested', 123, true); expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, - root: { - nested2: 123 + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' }, - canCreateApps: true + canCreateApps: true, + canCustomize: true, + root: { + nested: 123 + } }); uiState.get('root', {}).subscribe(x => { - expect(x).toEqual({ nested2: 123 }); + expect(x).toEqual({ nested: 123 }); }); - uiState.get('root.nested2', 0).subscribe(x => { + uiState.get('root.nested', 0).subscribe(x => { expect(x).toEqual(123); }); - uiState.get('root.nested1', 1337).subscribe(x => { + uiState.get('root.notfound', 1337).subscribe(x => { expect(x).toEqual(1337); }); - uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); + uiService.verifyAll(); + }); + + it('should remove value from snapshot and shared settings when removed', () => { + uiService.setup(x => x.deleteSharedSetting(app, 'map.key')) + .returns(() => of({})).verifiable(); + + uiState.remove('map.key'); + + expect(uiState.snapshot.settings).toEqual({ + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600 + }, + canCreateApps: true, + canCustomize: true + }); + + uiService.verifyAll(); + }); + + it('should remove value from snapshot and user settings when removed', () => { + uiService.setup(x => x.deleteUserSetting(app, 'map.sizeX')) + .returns(() => of({})).verifiable(); + + uiState.remove('map.sizeX'); + + expect(uiState.snapshot.settings).toEqual({ + key: 'xx', + map: { + type: 'GM', + sizeX: 800, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true + }); + + uiService.verifyAll(); }); }); \ 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 17508dc0f..879a95cf0 100644 --- a/src/Squidex/app/shared/state/ui.state.ts +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -10,6 +10,7 @@ import { distinctUntilChanged, map } from 'rxjs/operators'; import { hasAnyLink, + mergeInto, State, Types } from '@app/framework'; @@ -24,8 +25,11 @@ interface Snapshot { // All common settings. settingsCommon: object & any; - // All app settings. - settingsApp: object & any; + // All shared app settings. + settingsShared: object & any; + + // All user app settings. + settingsUser: object & any; // The merged settings of app and common settings. settings: object & any; @@ -48,6 +52,12 @@ export class UIState extends State { public settings = this.project(x => x.settings); + public settingsShared = + this.project(x => x.settingsShared); + + public settingsUser = + this.project(x => x.settingsUser); + public canReadEvents = this.project(x => x.canReadEvents === true); @@ -65,34 +75,54 @@ export class UIState extends State { distinctUntilChanged()); } + public getShared(path: string, defaultValue: T) { + return this.settingsShared.pipe(map(x => this.getValue(x, path, defaultValue)), + distinctUntilChanged()); + } + + public getUser(path: string, defaultValue: T) { + return this.settingsUser.pipe(map(x => this.getValue(x, path, defaultValue)), + distinctUntilChanged()); + } + constructor( private readonly appsState: AppsState, private readonly uiService: UIService, private readonly usersService: UsersService ) { - super({ settings: {}, settingsCommon: {}, settingsApp: {} }); + super({ + settings: {}, + settingsCommon: {}, + settingsShared: {}, + settingsUser: {} + }); this.loadResources(); this.loadCommon(); appsState.selectedValidApp.subscribe(app => { - this.load(); + this.load(app.name); }); } - private load() { - this.next(s => updateAppSettings(s, {})); + private load(app: string) { + this.next(s => updateSettings(s, {})); + + this.uiService.getSharedSettings(app) + .subscribe(payload => { + this.next(s => updateSettings(s, { settingsShared: payload })); + }); - this.uiService.getSettings(this.appName) + this.uiService.getUserSettings(app) .subscribe(payload => { - this.next(s => updateAppSettings(s, payload)); + this.next(s => updateSettings(s, { settingsUser: payload })); }); } private loadCommon() { this.uiService.getCommonSettings() .subscribe(payload => { - this.next(s => updateCommonSettings(s, payload)); + this.next(s => updateSettings(s, { settingsCommon: payload })); }); } @@ -107,60 +137,72 @@ export class UIState extends State { }); } - public set(path: string, value: any) { - const { key, current, root } = this.getContainer(path); + public set(path: string, value: any, user = false) { + if (user) { + this.setUser(path, value); + } else { + this.setShared(path, value); + } + } + + private setUser(path: string, value: any) { + const { key, current, root } = getContainer(this.snapshot.settingsUser, path); if (current && key) { - this.uiService.putSetting(this.appName, path, value).subscribe(); + this.uiService.putUserSetting(this.appName, path, value).subscribe(); current[key] = value; - this.next(s => updateAppSettings(s, root)); + this.next(s => updateSettings(s, { settingsUser: root })); } } - public remove(path: string) { - const { key, current, root } = this.getContainer(path); + private setShared(path: string, value: any) { + const { key, current, root } = getContainer(this.snapshot.settingsShared, path); if (current && key) { - this.uiService.deleteSetting(this.appName, path).subscribe(); + this.uiService.putSharedSetting(this.appName, path, value).subscribe(); - delete current[key]; + current[key] = value; - this.next(s => updateAppSettings(s, root)); + this.next(s => updateSettings(s, { settingsShared: root })); } } - private getContainer(path: string) { - const segments = path.split('.'); + public remove(path: string) { + return this.removeUser(path) || this.removeShared(path); + } - let current = { ...this.snapshot.settingsApp }; + public removeUser(path: string) { + const { key, current, root } = getContainer(this.snapshot.settingsUser, path); - const root = current; + if (current && key && current[key]) { + this.uiService.deleteUserSetting(this.appName, path).subscribe(); - if (segments.length > 0) { - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i]; + delete current[key]; - let temp = current[segment]; + this.next(s => updateSettings(s, { settingsUser: root })); - if (!temp) { - temp = {}; - } else { - temp = { ...temp }; - } + return true; + } - current[segment] = temp; + return false; + } - if (!Types.isObject(temp)) { - return { key: null, current: null, root: null }; - } + public removeShared(path: string) { + const { key, current, root } = getContainer(this.snapshot.settingsShared, path); - current = temp; - } + if (current && key && current[key]) { + this.uiService.deleteSharedSetting(this.appName, path).subscribe(); + + delete current[key]; + + this.next(s => updateSettings(s, { settingsShared: root })); + + return true; } - return { key: segments[segments.length - 1], current, root }; + return false; } private getValue(setting: object & UISettingsDto, path: string, defaultValue: T) { @@ -188,14 +230,44 @@ export class UIState extends State { } } -function updateAppSettings(state: Snapshot, settingsApp: object & any) { - const { settingsCommon } = state; +function getContainer(settings: object, path: string) { + const segments = path.split('.'); + + let current = { ...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]; - return { ...state, settings: { ...settingsCommon, ...settingsApp }, settingsApp, settingsCommon }; + 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 }; } -function updateCommonSettings(state: Snapshot, settingsCommon: object & any) { - const { settingsApp } = state; +function updateSettings(state: Snapshot, update: Partial) { + const settings = {}; + + mergeInto(settings, update.settingsCommon || state.settingsCommon); + mergeInto(settings, update.settingsShared || state.settingsShared); + mergeInto(settings, update.settingsUser || state.settingsUser); - return { ...state, settings: { ...settingsCommon, ...settingsApp }, settingsApp, settingsCommon }; + return { ...state, settings, ...update }; } \ 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 index 188e134bb..a5455e43b 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs @@ -35,6 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("key", 15); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] @@ -48,12 +51,16 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("key", 123); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] public async Task Should_remove_root_value() { await sut.SetAsync("key", JsonValue.Create(123).AsJ()); + await sut.RemoveAsync("key"); var actual = await sut.GetAsync(); @@ -61,6 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Apps var expected = JsonValue.Object(); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappenedTwiceExactly(); } [Fact] @@ -75,12 +85,16 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("nested", 123)); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] public async Task Should_remove_nested_value() { await sut.SetAsync("root.nested", JsonValue.Create(123).AsJ()); + await sut.RemoveAsync("root.nested"); var actual = await sut.GetAsync(); @@ -90,6 +104,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object()); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappenedTwiceExactly(); } [Fact] @@ -104,12 +121,18 @@ namespace Squidex.Domain.Apps.Entities.Apps public async Task Should_do_nothing_if_deleting_and_nested_not_found() { await sut.RemoveAsync("root.nested"); + + A.CallTo(() => grainState.WriteAsync()) + .MustNotHaveHappened(); } [Fact] public async Task Should_do_nothing_if_deleting_and_key_not_found() { await sut.RemoveAsync("root"); + + A.CallTo(() => grainState.WriteAsync()) + .MustNotHaveHappened(); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs new file mode 100644 index 000000000..63009bf1f --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// 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 Orleans; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Orleans; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppUISettingsTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly IAppUISettingsGrain grain = A.Fake(); + private readonly AppUISettings sut; + + public AppUISettingsTests() + { + A.CallTo(() => grainFactory.GetGrain(A.Ignored, null)) + .Returns(grain); + + sut = new AppUISettings(grainFactory); + } + + [Fact] + public async Task Should_call_grain_when_retrieving_settings() + { + var settings = JsonValue.Object(); + + A.CallTo(() => grain.GetAsync()) + .Returns(settings.AsJ()); + + var result = await sut.GetAsync(Guid.NewGuid(), "user"); + + Assert.Same(settings, result); + } + + [Fact] + public async Task Should_call_grain_when_setting_value() + { + var value = JsonValue.Object(); + + await sut.SetAsync(Guid.NewGuid(), "user", "the.path", value); + + A.CallTo(() => grain.SetAsync("the.path", A>.That.Matches(x => x.Value == value))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_when_replacing_settings() + { + var value = JsonValue.Object(); + + await sut.SetAsync(Guid.NewGuid(), "user", value); + + A.CallTo(() => grain.SetAsync(A>.That.Matches(x => x.Value == value))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_grain_when_removing_value() + { + await sut.RemoveAsync(Guid.NewGuid(), "user", "the.path"); + + A.CallTo(() => grain.RemoveAsync("the.path")) + .MustHaveHappened(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs new file mode 100644 index 000000000..82c936170 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// 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 Orleans; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class CommentsLoaderTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly CommentsLoader sut; + + public CommentsLoaderTests() + { + sut = new CommentsLoader(grainFactory); + } + + [Fact] + public async Task Should_get_comments_from_grain() + { + var commentsId = Guid.NewGuid(); + var comments = new CommentsResult(); + + var grain = A.Fake(); + + A.CallTo(() => grain.GetCommentsAsync(11)) + .Returns(comments); + + A.CallTo(() => grainFactory.GetGrain(commentsId, null)) + .Returns(grain); + + var result = await sut.GetCommentsAsync(commentsId, 11); + + Assert.Same(comments, result); + } + } +}