Browse Source

Tags

pull/308/head
Sebastian 8 years ago
parent
commit
704bdd8aef
  1. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  2. 6
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  3. 40
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  4. 14
      src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs
  5. 5
      src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs
  6. 2
      src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs
  7. 3
      src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs
  8. 6
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  9. 6
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  10. 49
      src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs
  11. 22
      src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs
  12. 22
      src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs
  13. 129
      src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  14. 17
      src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs
  15. 2
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  16. 5
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  17. 18
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs
  18. 4
      src/Squidex/Config/Domain/EntitiesServices.cs
  19. 2
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  20. 6
      src/Squidex/app/features/settings/pages/clients/client.component.html
  21. 43
      src/Squidex/app/features/settings/pages/clients/client.component.scss
  22. 23
      src/Squidex/app/framework/angular/forms/tag-editor.component.html
  23. 46
      src/Squidex/app/framework/angular/forms/tag-editor.component.scss
  24. 59
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  25. 6
      src/Squidex/app/framework/angular/image-source.directive.ts
  26. 54
      src/Squidex/app/shared/components/asset.component.html
  27. 14
      src/Squidex/app/shared/components/asset.component.scss
  28. 35
      src/Squidex/app/shared/components/asset.component.ts
  29. 33
      src/Squidex/app/shared/services/assets.service.spec.ts
  30. 15
      src/Squidex/app/shared/services/assets.service.ts
  31. 26
      src/Squidex/app/shared/state/assets.forms.ts
  32. 8
      src/Squidex/app/shared/state/assets.state.spec.ts
  33. 58
      src/Squidex/app/theme/_forms.scss
  34. 2
      src/Squidex/app/theme/_vars.scss
  35. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  36. 4
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs
  37. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs
  38. 2
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs
  39. 2
      tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs
  40. 2
      tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

4
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -38,6 +38,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
[BsonElement]
public string FileName { get; set; }
[BsonIgnoreIfNull]
[BsonElement]
public string[] Tags { get; set; }
[BsonRequired]
[BsonElement]
public long FileSize { get; set; }

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

@ -72,14 +72,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
});
case AssignContributor assigneContributor:
return UpdateReturnAsync(assigneContributor, (Func<AssignContributor, Task<object>>)(async c =>
return UpdateReturnAsync(assigneContributor, async c =>
{
await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId));
AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, (long)base.Version);
}));
return EntityCreatedResult.Create(c.ContributorId, (long)Version);
});
case RemoveContributor removeContributor:
return UpdateAsync(removeContributor, c =>

40
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Guards;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -24,33 +25,40 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots<AssetState>, IAssetGrain
{
public AssetGrain(IStore<Guid> store, ISemanticLog log)
private readonly ITagService tagService;
public AssetGrain(IStore<Guid> store, ITagService tagService, ISemanticLog log)
: base(store, log)
{
Guard.NotNull(tagService, nameof(tagService));
this.tagService = tagService;
}
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
VerifyNotDeleted();
switch (command)
{
case CreateAsset createRule:
return CreateReturnAsync(createRule, (Func<CreateAsset, object>)(c =>
return CreateReturnAsync(createRule, c =>
{
GuardAsset.CanCreate(c);
Create(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
return new AssetSavedResult(Version, Snapshot.FileVersion);
});
case UpdateAsset updateRule:
return UpdateReturnAsync(updateRule, (Func<UpdateAsset, object>)(c =>
return UpdateAsync(updateRule, c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
return new AssetSavedResult(Version, Snapshot.FileVersion);
});
case RenameAsset renameAsset:
return UpdateAsync(renameAsset, c =>
{
@ -65,6 +73,15 @@ namespace Squidex.Domain.Apps.Entities.Assets
Delete(c);
});
case TagAsset tagAsset:
return UpdateAsync(tagAsset, async c =>
{
GuardAsset.CanTag(c);
c.Tags = await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, "Assets", c.Tags, Snapshot.Tags);
Tag(c);
});
default:
throw new NotSupportedException();
}
@ -105,18 +122,19 @@ namespace Squidex.Domain.Apps.Entities.Assets
public void Delete(DeleteAsset command)
{
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize }));
}
public void Rename(RenameAsset command)
{
VerifyNotDeleted();
RaiseEvent(SimpleMapper.Map(command, new AssetRenamed()));
}
public void Tag(TagAsset command)
{
RaiseEvent(SimpleMapper.Map(command, new AssetTagged()));
}
private void RaiseEvent(AppEvent @event)
{
if (@event.AppId == null)

14
src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class TagAsset : AssetCommand
{
public string[] Tags { get; set; }
}
}

5
src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs

@ -35,6 +35,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards
Guard.NotNull(command, nameof(command));
}
public static void CanTag(TagAsset command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUpdate(UpdateAsset command)
{
Guard.NotNull(command, nameof(command));

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

@ -22,6 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
string MimeType { get; }
string[] Tags { get; }
long FileVersion { get; }
}
}

3
src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs

@ -28,6 +28,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
[JsonProperty]
public string MimeType { get; set; }
[JsonProperty]
public string[] Tags { get; set; }
[JsonProperty]
public long FileVersion { get; set; }

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

@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
switch (command)
{
case CreateContent createContent:
return CreateReturnAsync(createContent, (Func<CreateContent, Task<object>>)(async c =>
return CreateReturnAsync(createContent, async c =>
{
var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content.");
@ -77,8 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
Create(c);
return EntityCreatedResult.Create(c.Data, (long)base.Version);
}));
return EntityCreatedResult.Create(c.Data, (long)Version);
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c =>

6
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command)
{
case AddField addField:
return UpdateReturnAsync(addField, (Func<AddField, object>)(c =>
return UpdateAsync(addField, c =>
{
GuardSchemaField.CanAdd(Snapshot.SchemaDef, c);
@ -64,8 +64,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas
id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id;
}
return EntityCreatedResult.Create(id, (long)base.Version);
}));
return EntityCreatedResult.Create(id, (long)Version);
});
case CreateSchema createSchema:
return CreateAsync(createSchema, async c =>

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

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Tags
{
public sealed class GrainTagService : ITagService
{
private readonly IGrainFactory grainFactory;
public GrainTagService(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
this.grainFactory = grainFactory;
}
public Task<string[]> NormalizeTagsAsync(Guid appId, string category, string[] names, string[] ids)
{
return GetGrain(appId, category).NormalizeTagsAsync(names, ids);
}
public Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string category, string[] ids)
{
return GetGrain(appId, category).DenormalizeTagsAsync(ids);
}
public Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string category)
{
return GetGrain(appId, category).GetTagsAsync();
}
private ITagGrain GetGrain(Guid appId, string category)
{
Guard.NotNullOrEmpty(category, nameof(category));
return grainFactory.GetGrain<ITagGrain>($"{appId}_{category}");
}
}
}

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

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
namespace Squidex.Domain.Apps.Entities.Tags
{
public interface ITagGrain : IGrainWithStringKey
{
Task<string[]> NormalizeTagsAsync(string[] names, string[] ids);
Task<Dictionary<string, string>> DenormalizeTagsAsync(string[] ids);
Task<Dictionary<string, int>> GetTagsAsync();
}
}

22
src/Squidex.Domain.Apps.Entities/Tags/ITagService.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Tags
{
public interface ITagService
{
Task<string[]> NormalizeTagsAsync(Guid appId, string category, string[] names, string[] ids);
Task<Dictionary<string, string>> DenormalizeTagsAsync(Guid appId, string category, string[] ids);
Task<Dictionary<string, int>> GetTagsAsync(Guid appId, string category);
}
}

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

@ -0,0 +1,129 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Tags
{
public sealed class TagGrain : GrainOfString, ITagGrain
{
private readonly IStore<string> store;
private IPersistence<State> persistence;
private State state = new State();
[CollectionName("Index_Tags")]
public sealed class State
{
public Dictionary<string, TagInfo> Tags { get; set; } = new Dictionary<string, TagInfo>();
}
public sealed class TagInfo
{
public string Name { get; set; }
public int Count { get; set; } = 1;
}
public TagGrain(IStore<string> store)
{
Guard.NotNull(store, nameof(store));
this.store = store;
}
public override Task OnActivateAsync(string key)
{
persistence = store.WithSnapshots<TagGrain, State, string>(key, s =>
{
state = s;
});
return persistence.ReadAsync();
}
public async Task<string[]> NormalizeTagsAsync(string[] names, string[] ids)
{
var result = new List<string>();
if (names != null)
{
foreach (var tag in names)
{
if (!string.IsNullOrWhiteSpace(tag))
{
var tagName = tag.ToLowerInvariant();
var tagId = string.Empty;
var found = state.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, tagName, StringComparison.OrdinalIgnoreCase));
if (found.Value != null)
{
tagId = found.Key;
}
else
{
tagId = Guid.NewGuid().ToString();
state.Tags.Add(tagId, new TagInfo { Name = tagName });
}
result.Add(tagId);
}
}
}
if (ids != null)
{
foreach (var id in ids)
{
if (!result.Contains(id))
{
if (state.Tags.TryGetValue(id, out var tagInfo))
{
tagInfo.Count--;
if (tagInfo.Count <= 0)
{
state.Tags.Remove(id);
}
}
}
}
}
await persistence.WriteSnapshotAsync(state);
return result.ToArray();
}
public Task<Dictionary<string, string>> DenormalizeTagsAsync(string[] ids)
{
var result = new Dictionary<string, string>();
foreach (var id in ids)
{
if (state.Tags.TryGetValue(id, out var tagInfo))
{
result[id] = tagInfo.Name;
}
}
return Task.FromResult(result);
}
public Task<Dictionary<string, int>> GetTagsAsync()
{
return Task.FromResult(state.Tags.Values.ToDictionary(x => x.Name, x => x.Count));
}
}
}

17
src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Assets
{
[EventType(nameof(AssetTagged))]
public sealed class AssetTagged : AssetEvent
{
public string[] Tags { get; set; }
}
}

2
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -104,7 +104,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, true);
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), true);
}

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

@ -39,6 +39,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
public string FileType { get; set; }
/// <summary>
/// The asset tags.
/// </summary>
public string[] Tags { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>

18
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs

@ -8,7 +8,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
@ -20,9 +19,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
public string FileName { get; set; }
public RenameAsset ToCommand(Guid id)
/// <summary>
/// The new asset tags.
/// </summary>
[Required]
public string[] Tags { get; set; }
public AssetCommand ToCommand(Guid id)
{
return SimpleMapper.Map(this, new RenameAsset { AssetId = id });
if (Tags != null)
{
return new TagAsset { AssetId = id, Tags = Tags };
}
else
{
return new RenameAsset { AssetId = id, FileName = FileName };
}
}
}
}

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

@ -32,6 +32,7 @@ using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Indexes;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
@ -104,6 +105,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainTagService>()
.As<ITagService>();
services.AddSingletonAs<GrainCommandMiddleware<AppCommand, IAppGrain>>()
.As<ICommandMiddleware>();

2
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -2,7 +2,7 @@
<sqx-panel desiredWidth="60rem" [showSidebar]="true">
<ng-container title>
<i class="schema-edit icon-pencil" (click)="editSchemaDialog.show()"></i> {{schema.displayName}}
<i class="schema-edit icon-pencil" (click)="editSchemaDialog.show()"></i> <span (dblclick)="editSchemaDialog.show()">{{schema.displayName}}</span>
</ng-container>
<ng-container menu>

6
src/Squidex/app/features/settings/pages/clients/client.component.html

@ -6,7 +6,7 @@
<div class="form-group mr-1">
<sqx-control-errors for="name"></sqx-control-errors>
<input type="text" class="form-control client-name enabled" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" />
<input type="text" class="form-control client-name form-underlined" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" />
</div>
<button type="submit" class="btn btn-primary" [disabled]="!renameForm.form.valid || !renameForm.form.dirty">Save</button>
@ -17,8 +17,8 @@
</form>
<ng-container *ngIf="!isRenaming">
<h3 class="client-name">
<span (dblclick)="toggleRename()">{{client.name}}</span>
<h3 class="client-name" (dblclick)="toggleRename()">
{{client.name}}
</h3>
<i class="client-edit icon-pencil" (click)="toggleRename()"></i>

43
src/Squidex/app/features/settings/pages/clients/client.component.scss

@ -30,31 +30,13 @@ $color-editor: #eceeef;
}
&-name {
& {
@include border-radius(.25rem);
margin: 0;
margin-left: -.6rem;
height: 2.5rem;
padding: 0 .6rem;
border: 0;
background: transparent;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1.2rem;
font-weight: normal;
display: inline-block;
line-height: 2.5rem;
}
&.enabled,
&:hover {
& {
background: $color-editor;
}
}
h3 {
font-size: 1.6rem;
}
padding: .375rem 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 1.2rem;
font-weight: normal;
line-height: 1.5rem;
display: inline-block;
margin: 0;
}
&-header {
@ -66,12 +48,15 @@ $color-editor: #eceeef;
}
}
.col-form-label {
text-align: left;
h3 {
&.client-name {
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
}
}
.btn-cancel {
padding: .4rem;
.col-form-label {
text-align: left;
}
.form-check {

23
src/Squidex/app/framework/angular/forms/tag-editor.component.html

@ -1,12 +1,19 @@
<input type="text" class="form-control" [attr.name]="inputName" (keydown)="onKeyDown($event)" (blur)="markTouched()"
<div class="form-control {{class}}" (click)="input.focus()" [class.focus]="hasFocus">
<span class="items">
<span class="item-container" *ngFor="let item of items; let i = index" [class.disabled]="addInput.disabled">
<span class="item">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
</span>
</span>
</span>
<input type="text" class="blank" [attr.name]="inputName" (keydown)="onKeyDown($event)" #input
(focus)="focus()"
(blur)="markTouched()"
(input)="adjustSize($event.target)"
[formControl]="addInput"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="Press enter to add new item">
<div class="items">
<span class="item" *ngFor="let item of items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i>
</span>
</div>
placeholder="+Tag">
</div>

46
src/Squidex/app/framework/angular/forms/tag-editor.component.scss

@ -1,25 +1,57 @@
@import '_mixins';
@import '_vars';
.form-control {
& {
cursor: text;
}
&.focus {
@include box-shadow-raw(0 0 0 0.2rem rgba(51, 137, 255, 0.25));
border-color: #b3d3ff;
}
}
.blank {
& {
padding: 0;
border: 0;
background: transparent;
min-width: 40px;
}
&:focus,
&.focus {
@include box-shadow-none;
outline: none;
}
}
.icon-close {
font-size: .6rem;
}
.items {
margin-top: .4rem;
min-height: 1.6rem;
margin-left: -2px;
}
.item {
& {
@include border-radius(.8rem);
@include border-radius(10px);
display: inline-block;
color: $color-dark-foreground;
margin-right: .4rem;
margin-bottom: .25rem;
min-height: 1.6rem;
cursor: default;
height: 20px;
padding: 0 .6rem;
background: $color-theme-blue;
border: 0;
font-size: .8rem;
font-weight: normal;
line-height: 1.6rem;
line-height: 20px;
}
&-container {
padding: 2px;
}
&.disabled {

59
src/Squidex/app/framework/angular/forms/tag-editor.component.ts

@ -5,12 +5,13 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, forwardRef, Input } from '@angular/core';
import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Types } from '@app/framework/internal';
const KEY_ENTER = 13;
const KEY_SPACE = 32;
const KEY_DELETE = 8;
export interface Converter {
convert(input: string): any;
@ -81,9 +82,17 @@ export class TagEditorComponent implements ControlValueAccessor {
@Input()
public useDefaultValue = true;
@Input()
public class: string;
@Input()
public inputName = 'tag-editor';
@ViewChild('input')
public inputElement: ElementRef;
public hasFocus = false;
public items: any[] = [];
public addInput = new FormControl();
@ -118,23 +127,57 @@ export class TagEditorComponent implements ControlValueAccessor {
this.updateItems([...this.items.slice(0, index), ...this.items.splice(index + 1)]);
}
public markTouched() {
this.callTouched();
public focus() {
this.hasFocus = true;
}
private resetForm() {
this.adjustSize();
this.addInput.reset();
}
public markTouched() {
this.callTouched();
this.hasFocus = false;
}
public adjustSize() {
const style = window.getComputedStyle(this.inputElement.nativeElement);
if (!canvas) {
canvas = document.createElement('canvas');
}
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.font = `${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}`;
this.inputElement.nativeElement.style.width = <any>((ctx.measureText(this.inputElement.nativeElement.value).width + 20) + 'px');
}
}
}
public onKeyDown(event: KeyboardEvent) {
if (event.keyCode === KEY_ENTER) {
if (event.keyCode === KEY_SPACE) {
const value = <string>this.addInput.value;
if (this.converter.isValidInput(value)) {
if (value && this.converter.isValidInput(value)) {
const converted = this.converter.convert(value);
this.updateItems([...this.items, converted]);
this.resetForm();
return false;
}
} else if (event.keyCode === KEY_DELETE) {
const value = <string>this.addInput.value;
if (!value || value.length === 0) {
this.updateItems(this.items.slice(0, this.items.length - 2));
return false;
}
}
@ -151,4 +194,6 @@ export class TagEditorComponent implements ControlValueAccessor {
this.callChange(this.items);
}
}
}
}
let canvas: HTMLCanvasElement | null = null;

6
src/Squidex/app/framework/angular/image-source.directive.ts

@ -48,12 +48,12 @@ export class ImageSourceDirective implements OnChanges, OnDestroy, OnInit, After
this.parentResizeListener =
this.renderer.listen(this.parent, 'resize', () => {
this.resize(this.parent);
this.resize();
});
}
public ngAfterViewInit() {
this.resize(this.parent);
this.resize();
}
public ngOnChanges() {
@ -75,7 +75,7 @@ export class ImageSourceDirective implements OnChanges, OnDestroy, OnInit, After
this.retryLoadingImage();
}
private resize(parent: any) {
private resize() {
this.size = this.parent.getBoundingClientRect();
this.renderer.setStyle(this.element.nativeElement, 'display', 'inline-block');

54
src/Squidex/app/shared/components/asset.component.html

@ -1,4 +1,4 @@
<div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="selected.emit(asset)" (sqxFileDrop)="updateFile($event)">
<div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="selected.emit(asset)" (sqxFileDrop)="updateFile($event)">
<div class="card-body">
<div class="file-preview" *ngIf="asset && progress == 0" @fade>
<span class="file-type" *ngIf="asset.fileType">
@ -16,17 +16,14 @@
<div class="file-overlay-background"></div>
<div class="file-menu">
<a class="file-edit ml-1" *ngIf="!isDisabled" (click)="renameDialog.show()">
<i class="icon-pencil"></i>
</a>
<a class="file-download ml-1" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<a class="file-download" [attr.href]="asset | sqxAssetUrl" target="_blank" (click)="$event.stopPropagation()">
<i class="icon-download"></i>
</a>
<a class="file-delete ml-1" (click)="deleting.emit(asset); $event.stopPropagation()" *ngIf="!isDisabled && !removeMode">
<a class="file-delete ml-2" (click)="deleting.emit(asset); $event.stopPropagation()" *ngIf="!isDisabled && !removeMode">
<i class="icon-delete"></i>
</a>
<a class="file-delete ml-1" (click)="removing.emit(asset); $event.stopPropagation()" *ngIf="removeMode">
<a class="file-delete ml-2" (click)="removing.emit(asset); $event.stopPropagation()" *ngIf="removeMode">
<i class="icon-close"></i>
</a>
</div>
@ -44,8 +41,20 @@
</div>
</div>
<div class="card-footer" *ngIf="asset && progress == 0">
<div class="file-name" [attr.title]="asset.fileName">
{{asset.fileName}}
<div>
<div *ngIf="!renaming" class="file-name editable" [attr.title]="asset.fileName" (dblclick)="renameStart()">
{{asset.fileName}}
</div>
<div *ngIf="renaming">
<form [formGroup]="renameForm.form" (ngSubmit)="renameAsset()">
<sqx-control-errors for="name" [submitted]="renameForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control form-underlined editable" id="assetName" formControlName="name" autocomplete="off" sqxFocusOnInit (blur)="renameCancel()" />
</form>
</div>
</div>
<div>
<sqx-tag-editor class="blank"></sqx-tag-editor>
</div>
<div class="file-info">
<ng-container *ngIf="asset.pixelWidth">{{asset.pixelWidth}}x{{asset.pixelHeight}}px, </ng-container> {{asset.fileSize | sqxFileSize}}
@ -61,29 +70,4 @@
<span class="drop-overlay-text">Drop to update</span>
</div>
</div>
<ng-container *sqxModalView="renameDialog;onRoot:true">
<form [formGroup]="renameForm.form" (ngSubmit)="renameAsset()">
<sqx-modal-dialog (closed)="cancelRenameAsset()">
<ng-container title>
Rename asset
</ng-container>
<ng-container content>
<div class="form-group">
<label for="assetName">Name</label>
<sqx-control-errors for="name" [submitted]="renameForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" id="assetName" formControlName="name" autocomplete="off" sqxFocusOnInit />
</div>
</ng-container>
<ng-container footer>
<button type="reset" class="float-left btn btn-secondary" (click)="cancelRenameAsset()">Cancel</button>
<button type="submit" class="float-right btn btn-success">Rename</button>
</ng-container>
</sqx-modal-dialog>
</form>
</ng-container>
</div>

14
src/Squidex/app/shared/components/asset.component.scss

@ -73,7 +73,6 @@
.card {
& {
@include overlay-container;
height: $asset-height;
}
&.selectable {
@ -82,6 +81,7 @@
&-body {
position: relative;
height: 0.7 * $asset-height;
}
&-footer {
@ -89,7 +89,6 @@
background: transparent;
padding: .8rem;
padding-top: .4rem;
height: 70px;
}
}
@ -103,7 +102,8 @@
}
&-image {
height: 100%;
min-height: 100%;
max-height: 100%;
}
&-preview {
@ -122,6 +122,10 @@
}
}
&-info {
margin-top: .25rem;
}
&-user {
@include absolute(auto, auto, 1.7rem, .5rem);
}
@ -178,4 +182,8 @@
@include asset-type;
}
}
}
.editable {
height: 2rem;
}

35
src/Squidex/app/shared/components/asset.component.ts

@ -14,12 +14,11 @@ import {
AssetsService,
AuthService,
DateTime,
DialogModel,
DialogService,
fadeAnimation,
RenameAssetDto,
RenameAssetForm,
Types,
UpdateAssetDto,
Versioned
} from '@app/shared/internal';
@ -68,7 +67,9 @@ export class AssetComponent implements OnInit {
@Output()
public failed = new EventEmitter();
public renameDialog = new DialogModel();
public renaming = false;
public isTagging = false;
public renameForm = new RenameAssetForm(this.formBuilder);
public progress = 0;
@ -121,17 +122,16 @@ export class AssetComponent implements OnInit {
}
public renameAsset() {
const value = this.renameForm.submit();
const value = this.renameForm.submit(this.asset);
if (value) {
const requestDto = new UpdateAssetDto(value.name);
const requestDto = new RenameAssetDto(value.name);
this.assetsService.putAsset(this.appsState.appName, this.asset.id, requestDto, this.asset.version)
.subscribe(dto => {
this.updateAsset(this.asset.rename(requestDto.fileName, this.authState.user!.token, dto.version), true);
this.renameForm.submitCompleted();
this.renameDialog.hide();
this.renameCancel();
}, error => {
this.dialogs.notifyError(error);
@ -140,9 +140,22 @@ export class AssetComponent implements OnInit {
}
}
public cancelRenameAsset() {
public renameStart() {
if (!this.isDisabled) {
this.renameForm.load(this.asset);
this.renaming = true;
}
}
public renameCancel() {
this.renameForm.submitCompleted();
this.renameDialog.hide();
this.renaming = false;
}
public startTagging() {
if (!this.isDisabled) {
this.isTagging = true;
}
}
private setProgress(progress = 0) {
@ -162,14 +175,14 @@ export class AssetComponent implements OnInit {
}
private updateAsset(asset: AssetDto, emitEvent: boolean) {
this.renameForm.load({ name: asset.fileName });
this.asset = asset;
this.progress = 0;
if (emitEvent) {
this.emitUpdated(asset);
}
this.cancelRenameAsset();
this.renameCancel();
}
}

33
src/Squidex/app/shared/services/assets.service.spec.ts

@ -16,10 +16,11 @@ import {
AssetsDto,
AssetsService,
DateTime,
UpdateAssetDto,
RenameAssetDto,
Version,
Versioned
} from './../';
import { TagAssetDto } from '@appshared/services/assets.service';
describe('AssetDto', () => {
const creation = DateTime.today();
@ -30,7 +31,7 @@ describe('AssetDto', () => {
const newVersion = new Version('2');
it('should update name property and user info when renaming', () => {
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, 'url', version);
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, [], 'url', version);
const asset_2 = asset_1.rename('new-name.png', modifier, newVersion, modified);
expect(asset_2.fileName).toEqual('new-name.png');
@ -42,7 +43,7 @@ describe('AssetDto', () => {
it('should update file properties when uploading', () => {
const update = new AssetReplacedDto(2, 2, 'image/jpeg', true, 2, 2);
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, 'url', version);
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'name.png', 'png', 1, 1, 'image/png', false, 1, 1, [], 'url', version);
const asset_2 = asset_1.update(update, modifier, newVersion, modified);
expect(asset_2.fileSize).toEqual(2);
@ -110,6 +111,7 @@ describe('AssetsService', () => {
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
tags: undefined,
version: 11
},
{
@ -126,6 +128,7 @@ describe('AssetsService', () => {
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
tags: ['tag1', 'tag2'],
version: 22
}
]
@ -145,6 +148,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
[],
'http://service/p/api/assets/id1',
new Version('11')),
new AssetDto('id2', 'Created2', 'LastModifiedBy2',
@ -158,6 +162,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id2',
new Version('22'))
]));
@ -190,7 +195,8 @@ describe('AssetsService', () => {
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048
pixelHeight: 2048,
tags: ['tag1', 'tag2']
}, {
headers: {
etag: '2'
@ -210,6 +216,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
['tag1', 'tag2'],
'http://service/p/api/assets/id1',
new Version('2')));
}));
@ -284,6 +291,7 @@ describe('AssetsService', () => {
true,
1024,
2048,
[],
'http://service/p/api/assets/id1',
new Version('2')));
}));
@ -323,7 +331,22 @@ describe('AssetsService', () => {
it('should make put request to update asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const dto = new UpdateAssetDto('My-Asset.pdf');
const dto = new RenameAssetDto('My-Asset.pdf');
assetsService.putAsset('my-app', '123', dto, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({});
}));
it('should make put request to update asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const dto = new TagAssetDto(['tag1', 'tag2']);
assetsService.putAsset('my-app', '123', dto, version).subscribe();

15
src/Squidex/app/shared/services/assets.service.ts

@ -50,6 +50,7 @@ export class AssetDto extends Model {
public readonly isImage: boolean,
public readonly pixelWidth: number | null,
public readonly pixelHeight: number | null,
public readonly tags: string[],
public readonly url: string,
public readonly version: Version
) {
@ -79,13 +80,20 @@ export class AssetDto extends Model {
}
}
export class UpdateAssetDto {
export class RenameAssetDto {
constructor(
public readonly fileName: string
) {
}
}
export class TagAssetDto {
constructor(
public readonly tags: string[]
) {
}
}
export class AssetReplacedDto {
constructor(
public readonly fileSize: number,
@ -151,6 +159,7 @@ export class AssetsService {
item.isImage,
item.pixelWidth,
item.pixelHeight,
item.tags || [],
assetUrl,
new Version(item.version.toString()));
}));
@ -194,6 +203,7 @@ export class AssetsService {
response.isImage,
response.pixelWidth,
response.pixelHeight,
[],
assetUrl,
new Version(event.headers.get('etag')!));
@ -231,6 +241,7 @@ export class AssetsService {
body.isImage,
body.pixelWidth,
body.pixelHeight,
body.tags || [],
assetUrl,
response.version);
}),
@ -288,7 +299,7 @@ export class AssetsService {
pretifyError('Failed to delete asset. Please reload.'));
}
public putAsset(appName: string, id: string, dto: UpdateAssetDto, version: Version): Observable<Versioned<any>> {
public putAsset(appName: string, id: string, dto: RenameAssetDto | TagAssetDto, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`);
return HTTP.putVersioned(this.http, url, dto, version).pipe(

26
src/Squidex/app/shared/state/assets.forms.ts

@ -9,6 +9,8 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form } from '@app/framework';
import { AssetDto } from './../services/assets.service';
export class RenameAssetForm extends Form<FormGroup> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
@ -19,4 +21,28 @@ export class RenameAssetForm extends Form<FormGroup> {
]
}));
}
public submit(asset?: AssetDto) {
const result = super.submit();
if (asset) {
let index = asset.fileName.lastIndexOf('.');
if (index > 0) {
result.name += asset.fileName.substr(index);
}
}
return result;
}
public load(asset: AssetDto) {
let name = asset.fileName;
let index = name.lastIndexOf('.');
if (index > 0) {
name = name.substr(0, index);
}
super.load({ name });
}
}

8
src/Squidex/app/shared/state/assets.state.spec.ts

@ -30,8 +30,8 @@ describe('AssetsState', () => {
const newVersion = new Version('2');
const oldAssets = [
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, 'url1', version),
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, 'url2', version)
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, [], 'url1', version),
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, [], 'url2', version)
];
let dialogs: IMock<DialogService>;
@ -77,7 +77,7 @@ describe('AssetsState', () => {
});
it('should add asset to snapshot when created', () => {
const newAsset = new AssetDto('id3', creator, creator, creation, creation, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version);
const newAsset = new AssetDto('id3', creator, creator, creation, creation, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, [], 'url3', version);
assetsState.add(newAsset);
@ -86,7 +86,7 @@ describe('AssetsState', () => {
});
it('should update properties when updated', () => {
const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version);
const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, [], 'url3', version);
assetsState.update(newAsset);

58
src/Squidex/app/theme/_forms.scss

@ -4,16 +4,18 @@
//
// Support for Angular validation states.
//
.ng-invalid {
&.ng-dirty {
& {
border-color: $color-theme-error;
}
&:hover,
&:focus {
@include box-shadow-colored(0, 0, .2rem, $color-theme-error);
border-color: $color-theme-error-dark;
.form-control {
&.ng-invalid {
&.ng-dirty {
& {
border-color: $color-theme-error;
}
&:hover,
&:focus {
@include box-shadow-colored(0, 0, .2rem, $color-theme-error);
border-color: $color-theme-error-dark;
}
}
}
}
@ -176,3 +178,39 @@
color: $color-dark2-focus-foreground;
}
}
.form-underlined {
& {
@include border-radius(0);
padding-left: 0;
padding-right: 0;
border-color: transparent;
border-bottom: 1px solid $color-input-border;
}
&:focus,
&.focus {
@include box-shadow-none;
background: transparent;
border-color: transparent;
border-bottom-color: $color-theme-blue;
outline: none;
}
&.ng-invalid.ng-dirty {
& {
@include box-shadow-none;
background: transparent;
border-color: transparent;
border-bottom-color: $color-theme-error;
outline: none;
}
&:hover,
&:focus {
@include box-shadow-none;
border-color: transparent;
border-bottom-color: $color-theme-error-dark;
}
}
}

2
src/Squidex/app/theme/_vars.scss

@ -97,4 +97,4 @@ $panel-header: 5.4rem;
$panel-sidebar: 3.75rem;
$panel-light-background: #fff;
$asset-height: 13rem;
$asset-height: 16rem;

4
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -13,6 +13,7 @@ using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
@ -26,6 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly Guid assetId = Guid.NewGuid();
private readonly Stream stream = new MemoryStream();
@ -43,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
file = new AssetFile("my-image.png", "image/png", 1024, () => stream);
asset = new AssetGrain(Store, A.Dummy<ISemanticLog>());
asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
asset.OnActivateAsync(Id).Wait();
A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(Id, null))

4
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs

@ -11,6 +11,7 @@ using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
@ -23,6 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetGrainTests : HandlerTestBase<AssetGrain, AssetState>
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly ImageInfo image = new ImageInfo(2048, 2048);
private readonly Guid assetId = Guid.NewGuid();
private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream());
@ -35,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
public AssetGrainTests()
{
sut = new AssetGrain(Store, A.Dummy<ISemanticLog>());
sut = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
sut.OnActivateAsync(Id).Wait();
}

2
tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs

@ -34,6 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
public string FileName { get; set; }
public string[] Tags { get; set; }
public long FileSize { get; set; }
public long FileVersion { get; set; }

2
tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs

@ -90,7 +90,7 @@ namespace Squidex.Infrastructure.Commands
});
case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });

2
tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs

@ -85,7 +85,7 @@ namespace Squidex.Infrastructure.Commands
});
case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });

2
tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

@ -72,7 +72,7 @@ namespace Squidex.Infrastructure.TestHelpers
});
case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c =>
return UpdateAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });

Loading…
Cancel
Save