Browse Source

Continued with assets.

pull/363/head
Sebastian 7 years ago
parent
commit
41a49ca108
  1. 22
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  2. 20
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  3. 31
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  4. 25
      src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs
  5. 30
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  6. 108
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs
  7. 45
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  8. 17
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs
  9. 74
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs
  10. 30
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs
  11. 2
      src/Squidex/app/features/administration/pages/users/user-page.component.html
  12. 8
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  13. 6
      src/Squidex/app/framework/angular/http/http-extensions.ts
  14. 24
      src/Squidex/app/framework/utils/hateos.ts
  15. 2
      src/Squidex/app/shared/components/asset-dialog.component.html
  16. 19
      src/Squidex/app/shared/components/asset-dialog.component.ts
  17. 4
      src/Squidex/app/shared/components/asset.component.html
  18. 8
      src/Squidex/app/shared/components/asset.component.ts
  19. 4
      src/Squidex/app/shared/components/markdown-editor.component.ts
  20. 14
      src/Squidex/app/shared/components/pipes.ts
  21. 6
      src/Squidex/app/shared/components/rich-editor.component.ts
  22. 355
      src/Squidex/app/shared/services/assets.service.spec.ts
  23. 176
      src/Squidex/app/shared/services/assets.service.ts
  24. 3
      src/Squidex/app/shared/state/_test-helpers.ts
  25. 81
      src/Squidex/app/shared/state/asset-uploader.state.spec.ts
  26. 69
      src/Squidex/app/shared/state/asset-uploader.state.ts
  27. 39
      src/Squidex/app/shared/state/assets.state.spec.ts
  28. 2
      src/Squidex/app/shared/state/assets.state.ts
  29. 10
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs

22
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -70,13 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
if (IsDuplicate(createAsset, existing))
{
result = new AssetCreatedResult(
existing.Id,
existing.Tags,
existing.Version,
existing.FileVersion,
existing.FileHash,
true);
result = new AssetCreatedResult(existing, true);
}
break;
@ -89,17 +83,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
tagGenerator.GenerateTags(createAsset, createAsset.Tags);
}
var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset);
var asset = (IAssetEntity)await ExecuteCommandAsync(createAsset);
result = new AssetCreatedResult(
createAsset.AssetId,
createAsset.Tags,
commandResult.Version,
commandResult.FileVersion,
commandResult.FileHash,
false);
result = new AssetCreatedResult(asset, false);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null);
}
context.Complete(result);
@ -119,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
updateAsset.FileHash = await UploadAsync(context, updateAsset.File);
try
{
var result = (AssetSavedResult)await ExecuteCommandAsync(updateAsset);
var result = (IAssetEntity)await ExecuteCommandAsync(updateAsset);
context.Complete(result);

20
src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs

@ -5,29 +5,17 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCreatedResult : EntityCreatedResult<Guid>
public sealed class AssetCreatedResult
{
public HashSet<string> Tags { get; }
public long FileVersion { get; }
public string FileHash { get; }
public IAssetEntity Asset { get; }
public bool IsDuplicate { get; }
public AssetCreatedResult(Guid id, HashSet<string> tags, long version, long fileVersion, string fileHash, bool isDuplicate)
: base(id, version)
public AssetCreatedResult(IAssetEntity asset, bool isDuplicate)
{
Tags = tags;
FileVersion = fileVersion;
FileHash = fileHash;
Asset = asset;
IsDuplicate = isDuplicate;
}

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

@ -51,16 +51,27 @@ namespace Squidex.Domain.Apps.Entities.Assets
Create(c, tagIds);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
return await GetRawStateAsync();
});
case UpdateAsset updateRule:
return UpdateAsync(updateRule, c =>
return UpdateReturnAsync(updateRule, async c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
return await GetRawStateAsync();
});
case AnnotateAsset annotateAsset:
return UpdateReturnAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
return await GetRawStateAsync();
});
case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, async c =>
@ -71,15 +82,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
Delete(c);
});
case AnnotateAsset annotateAsset:
return UpdateAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
});
default:
throw new NotSupportedException();
}
@ -163,6 +165,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
}
}
public Task<IAssetEntity> GetRawStateAsync()
{
return Task.FromResult<IAssetEntity>(Snapshot);
}
public Task<J<IAssetEntity>> GetStateAsync(long version = EtagVersion.Any)
{
return J.AsTask<IAssetEntity>(GetSnapshot(version));

25
src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs

@ -1,25 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetSavedResult : EntitySavedResult
{
public long FileVersion { get; }
public string FileHash { get; }
public AssetSavedResult(long version, long fileVersion, string fileHash)
: base(version)
{
FileVersion = fileVersion;
FileHash = fileHash;
}
}
}

30
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -105,7 +105,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var response = AssetsDto.FromAssets(assets);
var response = AssetsDto.FromAssets(assets, this, app);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{
@ -142,7 +142,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
return NotFound();
}
var response = AssetDto.FromAsset(entity);
var response = AssetDto.FromAsset(entity, this, app);
if (controllerOptions.Value.EnableSurrogateKeys)
{
@ -169,8 +169,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpPost]
[Route("apps/{app}/assets/")]
[ProducesResponseType(typeof(AssetCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(AssetDto), 200)]
[AssetRequestSizeLimit]
[ApiPermission(Permissions.AppAssetsCreate)]
[ApiCosts(1)]
@ -182,7 +181,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetCreatedResult>();
var response = AssetCreatedDto.FromCommand(command, result);
var response = AssetDto.FromAsset(result.Asset, this, app, result.IsDuplicate);
return StatusCode(201, response);
}
@ -194,7 +193,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// <param name="id">The id of the asset.</param>
/// <param name="file">The file to upload.</param>
/// <returns>
/// 201 => Asset updated.
/// 200 => Asset updated.
/// 404 => Asset or app not found.
/// 400 => Asset exceeds the maximum size.
/// </returns>
@ -203,8 +202,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </remarks>
[HttpPut]
[Route("apps/{app}/assets/{id}/content/")]
[ProducesResponseType(typeof(AssetReplacedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(AssetDto), 200)]
[ApiPermission(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutAssetContent(string app, Guid id, [SwaggerIgnore] List<IFormFile> file)
@ -214,10 +212,10 @@ namespace Squidex.Areas.Api.Controllers.Assets
var command = new UpdateAsset { File = assetFile, AssetId = id };
var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetSavedResult>();
var response = AssetReplacedDto.FromCommand(command, result);
var result = context.Result<IAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
return StatusCode(201, response);
return Ok(response);
}
/// <summary>
@ -233,15 +231,19 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// </returns>
[HttpPut]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(AssetDto), 200)]
[AssetRequestSizeLimit]
[ApiPermission(Permissions.AppAssetsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutAsset(string app, Guid id, [FromBody] AnnotateAssetDto request)
{
await CommandBus.PublishAsync(request.ToCommand(id));
var command = request.ToCommand(id);
var context = await CommandBus.PublishAsync(command);
return NoContent();
var result = context.Result<IAssetEntity>();
var response = AssetDto.FromAsset(result, this, app);
return Ok(response);
}
/// <summary>

108
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs

@ -1,108 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetCreatedDto
{
/// <summary>
/// The id of the asset.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The file type.
/// </summary>
[Required]
public string FileType { get; set; }
/// <summary>
/// The file name.
/// </summary>
[Required]
public string FileName { get; set; }
/// <summary>
/// The slug.
/// </summary>
[Required]
public string Slug { get; set; }
/// <summary>
/// The mime type.
/// </summary>
[Required]
public string MimeType { get; set; }
/// <summary>
/// The default tags.
/// </summary>
[Required]
public HashSet<string> Tags { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// The version of the file.
/// </summary>
public long FileVersion { get; set; }
/// <summary>
/// Determines of the created file is an image.
/// </summary>
public bool IsImage { get; set; }
/// <summary>
/// The width of the image in pixels if the asset is an image.
/// </summary>
public int? PixelWidth { get; set; }
/// <summary>
/// The height of the image in pixels if the asset is an image.
/// </summary>
public int? PixelHeight { get; set; }
/// <summary>
/// Indicates if the asset has been already uploaded.
/// </summary>
public bool IsDuplicate { get; set; }
/// <summary>
/// The version of the asset.
/// </summary>
public long Version { get; set; }
public static AssetCreatedDto FromCommand(CreateAsset command, AssetCreatedResult result)
{
return new AssetCreatedDto
{
Id = result.IdOrValue,
FileName = command.File.FileName,
FileSize = command.File.FileSize,
FileType = command.File.FileName.FileType(),
FileVersion = result.FileVersion,
MimeType = command.File.MimeType,
IsImage = command.ImageInfo != null,
IsDuplicate = result.IsDuplicate,
PixelWidth = command.ImageInfo?.PixelWidth,
PixelHeight = command.ImageInfo?.PixelHeight,
Tags = result.Tags,
Version = result.Version
};
}
}
}

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

@ -8,15 +8,17 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
using NodaTime;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetDto : IGenerateETag
public sealed class AssetDto : Resource, IGenerateETag
{
/// <summary>
/// The id of the asset.
@ -110,9 +112,46 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public long Version { get; set; }
public static AssetDto FromAsset(IAssetEntity asset)
[JsonProperty("_meta")]
public AssetMetadata Meta { get; set; }
public static AssetDto FromAsset(IAssetEntity asset, ApiController controller, string app, bool isDuplicate = false)
{
var response = SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() });
if (isDuplicate)
{
response.Meta = new AssetMetadata { IsDuplicate = "true" };
}
return CreateLinks(response, controller, app);
}
private static AssetDto CreateLinks(AssetDto response, ApiController controller, string app)
{
return SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() });
var values = new { app, id = response.Id };
response.AddSelfLink(controller.Url<AssetsController>(x => nameof(x.GetAsset), values));
if (controller.HasPermission(Permissions.AppAssetsUpdate))
{
response.AddPutLink("update", controller.Url<AssetsController>(x => nameof(x.PutAsset), values));
response.AddPutLink("upload", controller.Url<AssetsController>(x => nameof(x.PutAssetContent), values));
}
if (controller.HasPermission(Permissions.AppAssetsDelete))
{
response.AddDeleteLink("delete", controller.Url<AssetsController>(x => nameof(x.DeleteAsset), values));
}
response.AddGetLink("content", controller.Url<AssetContentController>(x => nameof(x.GetAssetContent), new { id = response.Id, version = response.FileVersion }));
if (!string.IsNullOrWhiteSpace(response.Slug))
{
response.AddGetLink("content/slug", controller.Url<AssetContentController>(x => nameof(x.GetAssetContentBySlug), new { app, idOrSlug = response.Slug, version = response.Version }));
}
return response;
}
}
}

17
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetMetadata
{
/// <summary>
/// Indicates whether the asset is a duplicate.
/// </summary>
public string IsDuplicate { get; set; }
}
}

74
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs

@ -1,74 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetReplacedDto
{
/// <summary>
/// The mime type.
/// </summary>
[Required]
public string MimeType { get; set; }
/// <summary>
/// The file hash.
/// </summary>
[Required]
public string FileHash { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// The version of the file.
/// </summary>
public long FileVersion { get; set; }
/// <summary>
/// Determines of the created file is an image.
/// </summary>
public bool IsImage { get; set; }
/// <summary>
/// The width of the image in pixels if the asset is an image.
/// </summary>
public int? PixelWidth { get; set; }
/// <summary>
/// The height of the image in pixels if the asset is an image.
/// </summary>
public int? PixelHeight { get; set; }
/// <summary>
/// The version of the asset.
/// </summary>
public long Version { get; set; }
public static AssetReplacedDto FromCommand(UpdateAsset command, AssetSavedResult result)
{
var response = new AssetReplacedDto
{
FileSize = command.File.FileSize,
FileVersion = result.FileVersion,
MimeType = command.File.MimeType,
IsImage = command.ImageInfo != null,
PixelWidth = command.ImageInfo?.PixelWidth,
PixelHeight = command.ImageInfo?.PixelHeight,
Version = result.Version
};
return response;
}
}
}

30
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs

@ -9,10 +9,12 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetsDto
public sealed class AssetsDto : Resource
{
/// <summary>
/// The assets.
@ -25,9 +27,31 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public long Total { get; set; }
public static AssetsDto FromAssets(IResultList<IAssetEntity> assets)
public static AssetsDto FromAssets(IResultList<IAssetEntity> assets, ApiController controller, string app)
{
return new AssetsDto { Total = assets.Total, Items = assets.Select(AssetDto.FromAsset).ToArray() };
var response = new AssetsDto
{
Total = assets.Total,
Items = assets.Select(x => AssetDto.FromAsset(x, controller, app)).ToArray()
};
return CreateLinks(response, controller, app);
}
private static AssetsDto CreateLinks(AssetsDto response, ApiController controller, string app)
{
var values = new { app };
response.AddSelfLink(controller.Url<AssetsController>(x => nameof(x.GetAssets), values));
if (controller.HasPermission(Permissions.AppAssetsCreate))
{
response.AddPostLink("create", controller.Url<AssetsController>(x => nameof(x.PostAsset), values));
}
response.AddDeleteLink("tags", controller.Url<AssetsController>(x => nameof(x.GetTags), values));
return response;
}
}
}

2
src/Squidex/app/features/administration/pages/users/user-page.component.html

@ -16,7 +16,7 @@
<ng-container menu>
<ng-container *ngIf="usersState.selectedUser | async; let user; else noUserMenu">
<ng-container *ngIf="user | sqxHasLink: 'update'">
<ng-container *ngIf="!isReadOnly">
<button type="submit" class="btn btn-primary" title="CTRL + S">
Save
</button>

8
src/Squidex/app/features/administration/pages/users/user-page.component.ts

@ -29,6 +29,8 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
public user?: UserDto;
public userForm = new UserForm(this.formBuilder);
public isReadOnly = false;
constructor(
public readonly usersState: UsersState,
private readonly formBuilder: FormBuilder,
@ -47,7 +49,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
if (selectedUser) {
this.userForm.load(selectedUser);
if (!hasLink(selectedUser, 'update')) {
this.isReadOnly = !hasLink(this.user, 'update');
if (this.isReadOnly) {
this.userForm.form.disable();
}
}
@ -55,7 +59,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
}
public save() {
if (this.userForm.form.disabled) {
if (this.isReadOnly) {
return;
}

6
src/Squidex/app/framework/angular/http/http-extensions.ts

@ -47,6 +47,12 @@ export module HTTP {
return handleVersion(http.delete<T>(url, { observe: 'response', headers }));
}
export function requestVersioned<T>(http: HttpClient, method: string, url: string, version?: Version, body?: any): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.request<T>(method, url, { observe: 'response', headers, body }));
}
function createHeaders(version?: Version): HttpHeaders {
if (version && version.value && version.value.length > 0) {
return new HttpHeaders().set('If-Match', version.value);

24
src/Squidex/app/framework/utils/hateos.ts

@ -6,14 +6,22 @@
*/
export interface Resource {
readonly _links: { [rel: string]: ResourceLink };
_links: ResourceLinks;
_meta?: Metadata;
}
export type ResourceLinks = { [rel: string]: ResourceLink };
export type ResourceLink = { href: string; method: ResourceMethod; };
export type Metadata = { [rel: string]: string };
export function withLinks<T extends Resource>(value: T, source: Resource) {
if (value._links && source._links) {
if (!value._links) {
value._links = {};
}
for (let key in source._links) {
if (source._links.hasOwnProperty(key)) {
value._links[key] = source._links[key];
@ -23,6 +31,20 @@ export function withLinks<T extends Resource>(value: T, source: Resource) {
Object.freeze(value._links);
}
if (source._meta) {
if (!value._meta) {
value._meta = {};
}
for (let key in source._meta) {
if (source._meta.hasOwnProperty(key)) {
value._meta[key] = source._meta[key];
}
}
Object.freeze(value._meta);
}
return value;
}

2
src/Squidex/app/shared/components/asset-dialog.component.html

@ -38,7 +38,7 @@
<ng-container footer>
<button type="reset" class="float-left btn btn-secondary" (click)="emitCancel()">Cancel</button>
<button type="submit" class="float-right btn btn-primary">Save</button>
<button type="submit" class="float-right btn btn-primary" *ngIf="!isReadOnly">Save</button>
</ng-container>
</sqx-modal-dialog>
</form>

19
src/Squidex/app/shared/components/asset-dialog.component.ts

@ -13,7 +13,7 @@ import {
AppsState,
AssetDto,
AssetsService,
AuthService,
hasLink,
StatefulComponent
} from '@app/shared/internal';
@ -36,12 +36,13 @@ export class AssetDialogComponent extends StatefulComponent implements OnInit {
@Output()
public complete = new EventEmitter<AssetDto>();
public isReadOnly = false;
public annotateForm = new AnnotateAssetForm(this.formBuilder);
constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState,
private readonly assetsService: AssetsService,
private readonly authState: AuthService,
private readonly formBuilder: FormBuilder
) {
super(changeDetector, {
@ -53,6 +54,12 @@ export class AssetDialogComponent extends StatefulComponent implements OnInit {
public ngOnInit() {
this.annotateForm.load(this.asset);
this.isReadOnly = !hasLink(this.asset, 'update');
if (this.isReadOnly) {
this.annotateForm.form.disable();
}
}
public generateSlug() {
@ -68,12 +75,16 @@ export class AssetDialogComponent extends StatefulComponent implements OnInit {
}
public annotateAsset() {
if (this.isReadOnly) {
return;
}
const value = this.annotateForm.submit(this.asset);
if (value) {
this.assetsService.putAsset(this.appsState.appName, this.asset.id, value, this.asset.version)
this.assetsService.putAsset(this.appsState.appName, this.asset, value, this.asset.version)
.subscribe(dto => {
this.emitComplete(this.asset.annnotate(value, this.authState.user!.token, dto.version));
this.emitComplete(dto);
}, error => {
this.annotateForm.submitFailed(error);
});

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

@ -23,7 +23,7 @@
<a class="file-download ml-2" [href]="asset | sqxAssetUrl" sqxStopClick sqxExternalLink="noicon">
<i class="icon-download"></i>
</a>
<a class="file-delete ml-2" (click)="emitDelete()" *ngIf="!isDisabled && !removeMode">
<a class="file-delete ml-2" (click)="emitDelete()" *ngIf="!isDisabled && !removeMode && asset | sqxHasLink:'delete'">
<i class="icon-delete"></i>
</a>
<a class="file-delete ml-2" (click)="emitRemove()" *ngIf="removeMode">
@ -103,7 +103,7 @@
</a>
</td>
<td class="col-actions text-right" *ngIf="!isDisabled || removeMode">
<button type="button" class="btn btn-text-danger" (click)="emitDelete()" *ngIf="!isDisabled && !removeMode">
<button type="button" class="btn btn-text-danger" (click)="emitDelete()" *ngIf="!isDisabled && !removeMode && asset | sqxHasLink:'delete'">
<i class="icon-bin2"></i>
</button>
<button type="button" class="btn btn-text-secondary" (click)="emitRemove()" *ngIf="removeMode">

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

@ -9,13 +9,15 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Ho
import {
AssetDto,
AssetUploaderState,
DialogModel,
DialogService,
fadeAnimation,
hasLink,
StatefulComponent,
Types
Types,
UploadCanceled
} from '@app/shared/internal';
import { AssetUploaderState, UploadCanceled } from './../state/asset-uploader.state';
interface State {
progress: number;
@ -111,7 +113,7 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
}
public updateFile(files: FileList) {
if (files.length === 1) {
if (files.length === 1 && hasLink(this.asset, 'upload')) {
this.setProgress(1);
this.assetUploader.uploadAsset(this.asset, files[0])

4
src/Squidex/app/shared/components/markdown-editor.component.ts

@ -201,7 +201,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
let content = '';
for (let asset of assets) {
content += `![${asset.fileName}](${asset.url} '${asset.fileName}')`;
content += `![${asset.fileName}](${asset._links['content'].href} '${asset.fileName}')`;
}
if (content.length > 0) {
@ -244,7 +244,7 @@ export class MarkdownEditorComponent extends StatefulControlComponent<State, str
this.assetUploader.uploadFile(file)
.subscribe(asset => {
if (Types.is(asset, AssetDto)) {
replaceText(`![${asset.fileName}](${asset.url} '${asset.fileName}')`);
replaceText(`![${asset.fileName}](${asset._links['content'].href} '${asset.fileName}')`);
}
}, error => {
if (!Types.is(error, UploadCanceled)) {

14
src/Squidex/app/shared/components/pipes.ts

@ -16,6 +16,7 @@ import {
formatHistoryMessage,
HistoryEventDto,
MathHelper,
Resource,
UserDto,
UsersProviderService
} from '@app/shared/internal';
@ -215,8 +216,8 @@ export class UserPictureRefPipe extends UserAsyncPipe implements PipeTransform {
pure: true
})
export class AssetUrlPipe implements PipeTransform {
public transform(asset: { url: any }): string {
return `${asset.url}?q=${MathHelper.guid()}`;
public transform(asset: Resource): string {
return `${asset._links['content'].href}&sq=${MathHelper.guid()}`;
}
}
@ -225,13 +226,8 @@ export class AssetUrlPipe implements PipeTransform {
pure: true
})
export class AssetPreviewUrlPipe implements PipeTransform {
constructor(
private readonly apiUrl: ApiUrlConfig
) {
}
public transform(asset: { id: any, fileVersion: number }): string {
return this.apiUrl.buildUrl(`api/assets/${asset.id}?version=${asset.fileVersion}`);
public transform(asset: Resource): string {
return asset._links['content'].href;
}
}

6
src/Squidex/app/shared/components/rich-editor.component.ts

@ -98,7 +98,7 @@ export class RichEditorComponent extends StatefulControlComponent<any, string> i
this.assetUploader.uploadFile(file)
.subscribe(asset => {
if (Types.is(asset, AssetDto)) {
success(asset.url);
success(asset._links['content'].href);
}
}, error => {
if (!Types.is(error, UploadCanceled)) {
@ -186,7 +186,7 @@ export class RichEditorComponent extends StatefulControlComponent<any, string> i
let content = '';
for (let asset of assets) {
content += `<img src="${asset.url}" alt="${asset.fileName}" />`;
content += `<img src="${asset._links['content'].href}" alt="${asset.fileName}" />`;
}
if (content.length > 0) {
@ -216,7 +216,7 @@ export class RichEditorComponent extends StatefulControlComponent<any, string> i
this.assetUploader.uploadFile(file)
.subscribe(asset => {
if (Types.is(asset, AssetDto)) {
replaceText(`<img src="${asset.url}" alt="${asset.fileName}" />`);
replaceText(`<img src="${asset._links['content'].href}" alt="${asset.fileName}" />`);
}
}, error => {
if (!Types.is(error, UploadCanceled)) {

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

@ -12,64 +12,13 @@ import {
AnalyticsService,
ApiUrlConfig,
AssetDto,
AssetReplacedDto,
AssetsDto,
AssetsService,
DateTime,
ErrorDto,
Version,
Versioned
Resource,
Version
} from '@app/shared/internal';
import { AssetUploadedDto } from './assets.service';
describe('AssetDto', () => {
const creation = DateTime.today();
const creator = 'not-me';
const modified = DateTime.now();
const modifier = 'me';
const version = new Version('1');
const newVersion = new Version('2');
it('should update tag property and user info when annnoting', () => {
const update = { fileName: 'New-Name.png' };
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'Name.png', 'Hash', 'png', 1, 1, 'image/png', false, false, 1, 1, 'name.png', [], 'url', version);
const asset_2 = asset_1.annnotate(update, modifier, newVersion, modified);
expect(asset_2.fileName).toEqual(update.fileName);
expect(asset_2.tags).toEqual([]);
expect(asset_2.slug).toEqual(asset_1.slug);
expect(asset_2.lastModified).toEqual(modified);
expect(asset_2.lastModifiedBy).toEqual(modifier);
expect(asset_2.version).toEqual(newVersion);
});
it('should update file properties when uploading', () => {
const update = {
fileHash: 'Hash New',
fileSize: 1024,
fileVersion: 12,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048
};
const asset_1 = new AssetDto('1', creator, creator, creation, creation, 'Name.png', 'Hash', 'png', 1, 1, 'image/png', false, false, 1, 1, 'name.png', [], 'url', version);
const asset_2 = asset_1.update(update, modifier, newVersion, modified);
expect(asset_2.fileHash).toEqual(update.fileHash);
expect(asset_2.fileSize).toEqual(update.fileSize);
expect(asset_2.fileVersion).toEqual(update.fileVersion);
expect(asset_2.mimeType).toEqual(update.mimeType);
expect(asset_2.isImage).toBeTruthy();
expect(asset_2.pixelWidth).toEqual(update.pixelWidth);
expect(asset_2.pixelHeight).toEqual(update.pixelHeight);
expect(asset_2.lastModified).toEqual(modified);
expect(asset_2.lastModifiedBy).toEqual(modifier);
expect(asset_2.version).toEqual(newVersion);
});
});
describe('AssetsService', () => {
const version = new Version('1');
@ -133,85 +82,16 @@ describe('AssetsService', () => {
req.flush({
total: 10,
items: [
{
id: 'id1',
created: '2016-12-12T10:10',
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
fileName: 'My Asset1.png',
fileHash: 'My Hash1',
fileType: 'png',
fileSize: 1024,
fileVersion: 2000,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
slug: 'my-asset1.png',
tags: undefined,
version: 11
},
{
id: 'id2',
created: '2016-10-12T10:10',
createdBy: 'Created2',
lastModified: '2017-10-12T10:10',
lastModifiedBy: 'LastModifiedBy2',
fileName: 'My Asset2.png',
fileHash: 'My Hash1',
fileType: 'png',
fileSize: 1024,
fileVersion: 2000,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
slug: 'my-asset2.png',
tags: ['tag1', 'tag2'],
version: 22
}
assetResponse(12),
assetResponse(13)
]
});
expect(assets!).toEqual(
new AssetsDto(10, [
new AssetDto(
'id1', 'Created1', 'LastModifiedBy1',
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
'My Asset1.png',
'My Hash1',
'png',
1024,
2000,
'image/png',
false,
true,
1024,
2048,
'my-asset1.png',
[],
'http://service/p/api/assets/id1',
new Version('11')),
new AssetDto('id2', 'Created2', 'LastModifiedBy2',
DateTime.parseISO_UTC('2016-10-12T10:10'),
DateTime.parseISO_UTC('2017-10-12T10:10'),
'My Asset2.png',
'My Hash1',
'png',
1024,
2000,
'image/png',
false,
true,
1024,
2048,
'my-asset2.png',
['tag1', 'tag2'],
'http://service/p/api/assets/id2',
new Version('22'))
]));
createAsset(12),
createAsset(13)
]));
}));
it('should make get request to get asset',
@ -228,48 +108,9 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
id: 'id1',
created: '2016-12-12T10:10',
createdBy: 'Created1',
lastModified: '2017-12-12T10:10',
lastModifiedBy: 'LastModifiedBy1',
fileName: 'My Asset1.png',
fileHash: 'My Hash1',
fileType: 'png',
fileSize: 1024,
fileVersion: 2000,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
slug: 'my-asset1.png',
tags: ['tag1', 'tag2']
}, {
headers: {
etag: '2'
}
});
req.flush(assetResponse(12));
expect(asset!).toEqual(
new AssetDto(
'id1', 'Created1', 'LastModifiedBy1',
DateTime.parseISO_UTC('2016-12-12T10:10'),
DateTime.parseISO_UTC('2017-12-12T10:10'),
'My Asset1.png',
'My Hash1',
'png',
1024,
2000,
'image/png',
false,
true,
1024,
2048,
'my-asset1.png',
['tag1', 'tag2'],
'http://service/p/api/assets/id1',
new Version('2')));
expect(asset!).toEqual(createAsset(12));
}));
it('should append query to find by name',
@ -314,10 +155,10 @@ describe('AssetsService', () => {
it('should make post request to create asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: Versioned<AssetUploadedDto>;
let asset: AssetDto;
assetsService.uploadFile('my-app', null!).subscribe(result => {
asset = <Versioned<AssetUploadedDto>>result;
asset = <AssetDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets');
@ -325,54 +166,19 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
id: 'id1',
fileName: 'My Asset1.png',
fileHash: 'My Hash1',
fileType: 'png',
fileSize: 1024,
fileVersion: 2,
mimeType: 'image/png',
isDuplicate: true,
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
slug: 'my-asset1.png',
tags: ['tag1', 'tag2']
}, {
headers: {
etag: '1'
}
});
req.flush(assetResponse(12));
expect(asset!).toEqual({
payload: {
id: 'id1',
fileName: 'My Asset1.png',
fileHash: 'My Hash1',
fileType: 'png',
fileSize: 1024,
fileVersion: 2,
mimeType: 'image/png',
isDuplicate: true,
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048,
slug: 'my-asset1.png',
tags: ['tag1', 'tag2']
},
version
});
expect(asset!).toEqual(createAsset(12));
}));
it('should return proper error when upload failed with 413',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: Versioned<AssetUploadedDto>;
let asset: AssetDto;
let error: ErrorDto;
assetsService.uploadFile('my-app', null!).subscribe(result => {
asset = <Versioned<AssetUploadedDto>>result;
asset = <AssetDto>result;
}, e => {
error = e;
});
@ -391,10 +197,16 @@ describe('AssetsService', () => {
it('should make put request to replace asset content',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: AssetReplacedDto;
const resource: Resource = {
_links: {
upload: { method: 'PUT', href: 'api/apps/my-app/assets/123/content' }
}
};
let asset: AssetDto;
assetsService.replaceFile('my-app', '123', null!, version).subscribe(result => {
asset = (<Versioned<AssetReplacedDto>>result).payload;
assetsService.replaceFile('my-app', resource, null!, version).subscribe(result => {
asset = <AssetDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123/content');
@ -402,35 +214,29 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({
fileHash: 'Hash New',
fileSize: 1024,
fileVersion: 12,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048
req.flush(assetResponse(123), {
headers: {
etag: '1'
}
});
expect(asset!).toEqual({
fileHash: 'Hash New',
fileSize: 1024,
fileVersion: 12,
mimeType: 'image/png',
isImage: true,
pixelWidth: 1024,
pixelHeight: 2048
});
expect(asset!).toEqual(createAsset(123));
}));
it('should return proper error when replace failed with 413',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
let asset: AssetReplacedDto;
const resource: Resource = {
_links: {
upload: { method: 'PUT', href: 'api/apps/my-app/assets/123/content' }
}
};
let asset: AssetDto;
let error: ErrorDto;
assetsService.replaceFile('my-app', '123', null!, version).subscribe(result => {
asset = (<Versioned<AssetReplacedDto>>result).payload;
assetsService.replaceFile('my-app', resource, null!, version).subscribe(result => {
asset = <AssetDto>result;
}, e => {
error = e;
});
@ -451,20 +257,42 @@ describe('AssetsService', () => {
const dto = { fileName: 'New-Name.png' };
assetsService.putAsset('my-app', '123', dto, version).subscribe();
const resource: Resource = {
_links: {
update: { method: 'PUT', href: 'api/apps/my-app/assets/123' }
}
};
let asset: AssetDto;
assetsService.putAsset('my-app', resource, dto, version).subscribe(result => {
asset = result;
});
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({});
req.flush(assetResponse(123), {
headers: {
etag: '1'
}
});
expect(asset!).toEqual(createAsset(123));
}));
it('should make delete request to delete asset',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
assetsService.deleteAsset('my-app', '123', version).subscribe();
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: 'api/apps/my-app/assets/123' }
}
};
assetsService.deleteAsset('my-app', resource, version).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/assets/123');
@ -473,4 +301,61 @@ describe('AssetsService', () => {
req.flush({});
}));
function assetResponse(id: number, suffix = '') {
return {
id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10`,
createdBy: `creator-${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10`,
lastModifiedBy: `modifier-${id}`,
fileName: `My Name${id}${suffix}.png`,
fileHash: `My Hash${id}${suffix}`,
fileType: 'png',
fileSize: id * 2,
fileVersion: id * 4,
mimeType: 'image/png',
isImage: true,
pixelWidth: id * 3,
pixelHeight: id * 5,
slug: `my-name${id}${suffix}.png`,
tags: ['tag1', 'tag2'],
version: id,
_links: {
update: { method: 'PUT', href: `/assets/${id}` }
},
_meta: {
isDuplicate: 'true'
}
};
}
});
export function createAsset(id: number, tags?: string[], suffix = '') {
const result = new AssetDto(
`id${id}`,
`creator-${id}`,
`modifier-${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10`),
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10`),
`My Name${id}${suffix}.png`,
`My Hash${id}${suffix}`,
'png',
id * 2,
id * 4,
'image/png',
true,
id * 3,
id * 5,
`my-name${id}${suffix}.png`,
tags || ['tag1', 'tag2'],
new Version(`${id}`));
result._links['update'] = {
method: 'PUT', href: `/assets/${id}`
};
result._meta['isDuplicate'] = 'true';
return result;
}

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

@ -16,22 +16,31 @@ import {
DateTime,
ErrorDto,
HTTP,
Metadata,
Model,
pretifyError,
Resource,
ResourceLinks,
ResultSet,
Types,
Version,
Versioned,
versioned
withLinks
} from '@app/framework';
export class AssetsDto extends ResultSet<AssetDto> { }
export class AssetsDto extends ResultSet<AssetDto> {
public readonly _links: ResourceLinks = {};
}
export class AssetDto extends Model<AssetDto> {
public get canPreview() {
return this.isImage || (this.mimeType === 'image/svg+xml' && this.fileSize < 100 * 1024);
}
public readonly _meta: Metadata = {};
public readonly _links: ResourceLinks = {};
constructor(
public readonly id: string,
public readonly createdBy: string,
@ -44,35 +53,15 @@ export class AssetDto extends Model<AssetDto> {
public readonly fileSize: number,
public readonly fileVersion: number,
public readonly mimeType: string,
public readonly isDuplicate: boolean,
public readonly isImage: boolean,
public readonly pixelWidth: number | null | undefined,
public readonly pixelHeight: number | null | undefined,
public readonly slug: string,
public readonly tags: string[],
public readonly url: string,
public readonly version: Version
) {
super();
}
public update(update: AssetReplacedDto, user: string, version: Version, now?: DateTime): AssetDto {
return this.with({
...update,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
});
}
public annnotate(update: AnnotateAssetDto, user: string, version: Version, now?: DateTime): AssetDto {
return this.with({
...<any>update,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version
}, true);
}
}
export interface AnnotateAssetDto {
@ -81,25 +70,6 @@ export interface AnnotateAssetDto {
readonly tags?: string[];
}
export interface AssetReplacedDto {
readonly fileHash: string;
readonly fileSize: number;
readonly fileVersion: number;
readonly mimeType: string;
readonly isImage: boolean;
readonly pixelWidth?: number | null;
readonly pixelHeight?: number | null;
}
export interface AssetUploadedDto extends AssetReplacedDto {
readonly id: string;
readonly fileName: string;
readonly fileType: string;
readonly slug: string;
readonly isDuplicate: boolean;
readonly tags?: string[];
}
@Injectable()
export class AssetsService {
constructor(
@ -150,42 +120,17 @@ export class AssetsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets?${fullQuery}`);
return HTTP.getVersioned<any>(this.http, url).pipe(
map(({ payload }) => {
const body = payload.body;
map(({ payload }) => {
const { total, items } = <{ total: number, items: any[] }>payload.body;
const assets = new AssetsDto(total, items.map(item => parseAsset(item)));
const items: any[] = body.items;
const assets = new AssetsDto(body.total, items.map(item => {
const assetUrl = this.apiUrl.buildUrl(`api/assets/${item.id}`);
return new AssetDto(
item.id,
item.createdBy,
item.lastModifiedBy,
DateTime.parseISO_UTC(item.created),
DateTime.parseISO_UTC(item.lastModified),
item.fileName,
item.fileHash,
item.fileType,
item.fileSize,
item.fileVersion,
item.mimeType,
false,
item.isImage,
item.pixelWidth,
item.pixelHeight,
item.slug,
item.tags || [],
assetUrl,
new Version(item.version.toString()));
}));
return assets;
return withLinks(assets, payload.body);
}),
pretifyError('Failed to load assets. Please reload.'));
}
public uploadFile(appName: string, file: File): Observable<number | Versioned<AssetUploadedDto>> {
public uploadFile(appName: string, file: File): Observable<number | AssetDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets`);
const req = new HttpRequest('POST', url, getFormData(file), { reportProgress: true });
@ -200,9 +145,7 @@ export class AssetsService {
return percentDone;
} else if (Types.is(event, HttpResponse)) {
const response: any = event.body;
return versioned(new Version(event.headers.get('etag')!), response);
return parseAsset(event.body);
} else {
throw 'Invalid';
}
@ -226,39 +169,20 @@ export class AssetsService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`);
return HTTP.getVersioned<any>(this.http, url).pipe(
map(({ version, payload }) => {
map(({ payload }) => {
const body = payload.body;
const assetUrl = this.apiUrl.buildUrl(`api/assets/${body.id}`);
return new AssetDto(
body.id,
body.createdBy,
body.lastModifiedBy,
DateTime.parseISO_UTC(body.created),
DateTime.parseISO_UTC(body.lastModified),
body.fileName,
body.fileHash,
body.fileType,
body.fileSize,
body.fileVersion,
body.mimeType,
false,
body.isImage,
body.pixelWidth,
body.pixelHeight,
body.slug,
body.tags || [],
assetUrl,
version);
return parseAsset(body);
}),
pretifyError('Failed to load assets. Please reload.'));
}
public replaceFile(appName: string, id: string, file: File, version: Version): Observable<number | Versioned<AssetReplacedDto>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}/content`);
public replaceFile(appName: string, asset: Resource, file: File, version: Version): Observable<number | AssetDto> {
const link = asset._links['upload'];
const url = this.apiUrl.buildUrl(link.href);
const req = new HttpRequest('PUT', url, getFormData(file), { headers: new HttpHeaders().set('If-Match', version.value), reportProgress: true });
const req = new HttpRequest(link.method, url, getFormData(file), { headers: new HttpHeaders().set('If-Match', version.value), reportProgress: true });
return this.http.request(req).pipe(
filter(event =>
@ -270,9 +194,7 @@ export class AssetsService {
return percentDone;
} else if (Types.is(event, HttpResponse)) {
const response: any = event.body;
return versioned(new Version(event.headers.get('etag')!), response);
return parseAsset(event.body);
} else {
throw 'Invalid';
}
@ -292,22 +214,29 @@ export class AssetsService {
pretifyError('Failed to replace asset. Please reload.'));
}
public deleteAsset(appName: string, id: string, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`);
public putAsset(appName: string, asset: Resource, dto: AnnotateAssetDto, version: Version): Observable<AssetDto> {
const link = asset._links['update'];
return HTTP.deleteVersioned(this.http, url, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseAsset(payload.body);
}),
tap(() => {
this.analytics.trackEvent('Analytics', 'Deleted', appName);
this.analytics.trackEvent('Analytics', 'Updated', appName);
}),
pretifyError('Failed to delete asset. Please reload.'));
}
public putAsset(appName: string, id: string, dto: AnnotateAssetDto, version: Version): Observable<Versioned<any>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/${id}`);
public deleteAsset(appName: string, asset: Resource, version: Version): Observable<Versioned<any>> {
const link = asset._links['delete'];
return HTTP.putVersioned(this.http, url, dto, version).pipe(
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
tap(() => {
this.analytics.trackEvent('Analytics', 'Updated', appName);
this.analytics.trackEvent('Analytics', 'Deleted', appName);
}),
pretifyError('Failed to delete asset. Please reload.'));
}
@ -320,3 +249,26 @@ function getFormData(file: File) {
return formData;
}
function parseAsset(response: any) {
return withLinks(
new AssetDto(
response.id,
response.createdBy,
response.lastModifiedBy,
DateTime.parseISO_UTC(response.created),
DateTime.parseISO_UTC(response.lastModified),
response.fileName,
response.fileHash,
response.fileType,
response.fileSize,
response.fileVersion,
response.mimeType,
response.isImage,
response.pixelWidth,
response.pixelHeight,
response.slug,
response.tags || [],
new Version(response.version.toString())),
response);
}

3
src/Squidex/app/shared/state/_test-helpers.ts

@ -31,6 +31,9 @@ appsState.setup(x => x.appName)
appsState.setup(x => x.selectedApp)
.returns(() => of(<any>{ name: app }));
appsState.setup(x => x.selectedValidApp)
.returns(() => of(<any>{ name: app }));
const authService = Mock.ofType<AuthService>();
authService.setup(x => x.user)

81
src/Squidex/app/shared/state/asset-uploader.state.spec.ts

@ -10,61 +10,35 @@ import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock } from 'typemoq';
import {
ApiUrlConfig,
AssetDto,
AssetReplacedDto,
AssetsService,
AssetUploaderState,
DialogService,
ofForever,
Types,
Version,
versioned
Types
} from '@app/shared/internal';
import { createAsset } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers';
describe('AssetsState', () => {
const {
app,
appsState,
authService,
modified,
modifier,
version
appsState
} = TestValues;
let assetsService: IMock<AssetsService>;
let dialogs: IMock<DialogService>;
let assetUploader: AssetUploaderState;
const asset = new AssetDto('id1',
modifier,
modifier,
modified,
modified,
'my-asset',
'my-hash',
'png',
100,
1,
'image/png',
true,
true,
800,
600,
'my-slug',
[],
'http://url/api/assets/id1',
version);
const asset = createAsset(1);
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
const apiUrl = new ApiUrlConfig('http://url');
assetsService = Mock.ofType<AssetsService>();
assetUploader = new AssetUploaderState(appsState.object, apiUrl, assetsService.object, authService.object, dialogs.object);
assetUploader = new AssetUploaderState(appsState.object, assetsService.object, dialogs.object);
});
afterEach(() => {
@ -77,7 +51,7 @@ describe('AssetsState', () => {
assetsService.setup(x => x.uploadFile(app, file))
.returns(() => never()).verifiable();
assetUploader.uploadFile(file, undefined, modified).subscribe();
assetUploader.uploadFile(file).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -91,7 +65,7 @@ describe('AssetsState', () => {
assetsService.setup(x => x.uploadFile(app, file))
.returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadFile(file, undefined, modified).subscribe();
assetUploader.uploadFile(file).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -105,7 +79,7 @@ describe('AssetsState', () => {
assetsService.setup(x => x.uploadFile(app, file))
.returns(() => throwError('Error')).verifiable();
assetUploader.uploadFile(file, undefined, modified).pipe(onErrorResumeNext()).subscribe();
assetUploader.uploadFile(file).pipe(onErrorResumeNext()).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -117,11 +91,11 @@ describe('AssetsState', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file))
.returns(() => of(10, 20, versioned(version, { ...asset }))).verifiable();
.returns(() => of(10, 20, asset)).verifiable();
let uploadedAsset: AssetDto;
assetUploader.uploadFile(file, undefined, modified).subscribe(dto => {
assetUploader.uploadFile(file).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
@ -139,10 +113,10 @@ describe('AssetsState', () => {
it('should create initial state when uploading asset', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset.id, file, asset.version))
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version))
.returns(() => never()).verifiable();
assetUploader.uploadAsset(asset, file, modified).subscribe();
assetUploader.uploadAsset(asset, file).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -153,10 +127,10 @@ describe('AssetsState', () => {
it('should update progress when uploading asset makes progress', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset.id, file, asset.version))
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version))
.returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadAsset(asset, file, modified).subscribe();
assetUploader.uploadAsset(asset, file).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -167,10 +141,10 @@ describe('AssetsState', () => {
it('should update status when uploading asset failed', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset.id, file, asset.version))
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version))
.returns(() => throwError('Error')).verifiable();
assetUploader.uploadAsset(asset, file, modified).pipe(onErrorResumeNext()).subscribe();
assetUploader.uploadAsset(asset, file).pipe(onErrorResumeNext()).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
@ -181,25 +155,14 @@ describe('AssetsState', () => {
it('should update status when uploading asset completes', () => {
const file: File = <any>{ name: 'my-file' };
let update: AssetReplacedDto = {
isImage: true,
mimeType: 'image/jpeg',
pixelWidth: 800,
pixelHeight: 600,
fileHash: 'my-hash2',
fileSize: 200,
fileVersion: 2
};
const newVersion = new Version('2');
const newAsset = asset.update(update, modifier, newVersion, modified);
let updated = createAsset(1, undefined, '-new');
assetsService.setup(x => x.replaceFile(app, asset.id, file, asset.version))
.returns(() => of(10, 20, versioned(newVersion, update))).verifiable();
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version))
.returns(() => of(10, 20, updated)).verifiable();
let uploadedAsset: AssetDto;
assetUploader.uploadAsset(asset, file, modified).subscribe(dto => {
assetUploader.uploadAsset(asset, file).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
@ -209,6 +172,6 @@ describe('AssetsState', () => {
expect(upload.status).toBe('Completed');
expect(upload.progress).toBe(100);
expect(uploadedAsset!).toEqual(newAsset);
expect(uploadedAsset!).toEqual(updated);
});
});

69
src/Squidex/app/shared/state/asset-uploader.state.ts

@ -10,18 +10,14 @@ import { Observable, Subject } from 'rxjs';
import { distinctUntilChanged, map, publishReplay, refCount, takeUntil } from 'rxjs/operators';
import {
ApiUrlConfig,
DateTime,
DialogService,
ImmutableArray,
MathHelper,
State,
Types,
Versioned
Types
} from '@app/framework';
import { AssetDto, AssetsService, AssetUploadedDto } from './../services/assets.service';
import { AuthService } from './../services/auth.service';
import { AssetDto, AssetsService } from './../services/assets.service';
import { AppsState } from './apps.state';
import { AssetsState } from './assets.state';
@ -60,9 +56,7 @@ export class AssetUploaderState extends State<Snapshot> {
constructor(
private readonly appsState: AppsState,
private readonly apiUrl: ApiUrlConfig,
private readonly assetsService: AssetsService,
private readonly authService: AuthService,
private readonly dialogs: DialogService
) {
super({ uploads: ImmutableArray.empty() });
@ -78,13 +72,11 @@ export class AssetUploaderState extends State<Snapshot> {
});
}
public uploadFile(file: File, target?: AssetsState, now?: DateTime): Observable<UploadResult> {
public uploadFile(file: File, target?: AssetsState): Observable<UploadResult> {
const stream = this.assetsService.uploadFile(this.appName, file);
return this.upload(stream, MathHelper.guid(), file, response => {
const asset = createAsset(response, this.apiUrl, this.user, now);
if (asset.isDuplicate) {
return this.upload(stream, MathHelper.guid(), file, asset => {
if (asset._meta && asset._meta['isDuplicate'] === 'true') {
this.dialogs.notifyError('Asset has already been uploaded.');
} else if (target) {
target.add(asset);
@ -94,17 +86,13 @@ export class AssetUploaderState extends State<Snapshot> {
});
}
public uploadAsset(asset: AssetDto, file: File, now?: DateTime): Observable<UploadResult> {
const stream = this.assetsService.replaceFile(this.appName, asset.id, file, asset.version);
return this.upload(stream, asset.id, file, ({ version, payload }) => {
const newAsset = asset.update(payload, this.user, version, now);
public uploadAsset(asset: AssetDto, file: File): Observable<UploadResult> {
const stream = this.assetsService.replaceFile(this.appName, asset, file, asset.version);
return newAsset;
});
return this.upload(stream, asset.id, file);
}
private upload<T>(source: Observable<number | T>, id: string, file: File, complete: ((completion: T) => AssetDto)) {
private upload(source: Observable<number | AssetDto>, id: string, file: File, complete?: ((completion: AssetDto) => AssetDto)) {
let upload = { id, name: file.name, progress: 1, status: 'Running', cancel: new Subject() };
this.addUpload(upload);
@ -114,7 +102,11 @@ export class AssetUploaderState extends State<Snapshot> {
if (Types.isNumber(event)) {
return event;
} else {
return complete(event);
if (complete) {
return complete(event);
} else {
return event;
}
}
}),
publishReplay(), refCount());
@ -170,37 +162,4 @@ export class AssetUploaderState extends State<Snapshot> {
private get appName() {
return this.appsState.appName;
}
private get user() {
return this.authService.user!.token;
}
}
function createAsset({ payload, version }: Versioned<AssetUploadedDto>, apiUrl: ApiUrlConfig, user: string, now?: DateTime) {
const assetUrl = apiUrl.buildUrl(`api/assets/${payload.id}`);
now = now || DateTime.now();
const asset = new AssetDto(
payload.id,
user,
user,
now,
now,
payload.fileName,
payload.fileHash,
payload.fileType,
payload.fileSize,
payload.fileVersion,
payload.mimeType,
payload.isDuplicate,
payload.isImage,
payload.pixelWidth,
payload.pixelHeight,
payload.slug,
payload.tags || [],
assetUrl,
version);
return asset;
}

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

@ -9,7 +9,6 @@ import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import {
AssetDto,
AssetsDto,
AssetsService,
AssetsState,
@ -17,26 +16,22 @@ import {
versioned
} from '@app/shared/internal';
import { createAsset } from './../services/assets.service.spec';
import { TestValues } from './_test-helpers';
describe('AssetsState', () => {
const {
app,
appsState,
creation,
creator,
modified,
modifier,
newVersion,
version
} = TestValues;
const oldAssets = [
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'hash1', 'type1', 1, 1, 'mime1', false, false, null, null, 'slug1', ['tag1', 'shared'], 'url1', version),
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'hash2', 'type2', 2, 2, 'mime2', false, false, null, null, 'slug2', ['tag2', 'shared'], 'url2', version)
];
const asset1 = createAsset(1, ['tag1', 'shared']);
const asset2 = createAsset(2, ['tag2', 'shared']);
const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'hash3', 'type3', 3, 3, 'mime3', false, true, 0, 0, 'slug3', ['new'], 'url3', version);
const newAsset = createAsset(3, ['new']);
let dialogs: IMock<DialogService>;
let assetsService: IMock<AssetsService>;
@ -56,14 +51,14 @@ describe('AssetsState', () => {
describe('Loading', () => {
it('should load assets', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(200, oldAssets))).verifiable();
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
assetsState.load().subscribe();
expect(assetsState.snapshot.assets.values).toEqual(oldAssets);
expect(assetsState.snapshot.assets.values).toEqual([asset1, asset2]);
expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200);
expect(assetsState.snapshot.isLoaded).toBeTruthy();
@ -72,7 +67,7 @@ describe('AssetsState', () => {
it('should show notification on load when reload is true', () => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(200, oldAssets)));
.returns(() => of(new AssetsDto(200, [asset1, asset2])));
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
@ -147,7 +142,7 @@ describe('AssetsState', () => {
describe('Updates', () => {
beforeEach(() => {
assetsService.setup(x => x.getAssets(app, 30, 0, undefined, []))
.returns(() => of(new AssetsDto(200, oldAssets))).verifiable();
.returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable();
assetsService.setup(x => x.getTags(app))
.returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable();
@ -158,7 +153,7 @@ describe('AssetsState', () => {
it('should add asset to snapshot when created', () => {
assetsState.add(newAsset);
expect(assetsState.snapshot.assets.values).toEqual([newAsset, ...oldAssets]);
expect(assetsState.snapshot.assets.values).toEqual([newAsset, asset1, asset2]);
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(201);
});
@ -169,20 +164,22 @@ describe('AssetsState', () => {
expect(assetsState.snapshot.tags).toEqual({ tag1: 1, tag2: 1, shared: 2, new: 2 });
});
it('should update properties when updated', () => {
assetsState.update(newAsset);
it('should update asset when updated', () => {
const update = createAsset(1, ['new'], '-new');
assetsState.update(update);
const asset_1 = assetsState.snapshot.assets.at(0);
const newAsset1 = assetsState.snapshot.assets.at(0);
expect(asset_1).toBe(newAsset);
expect(newAsset1).toEqual(update);
expect(assetsState.snapshot.tags).toEqual({ tag2: 1, shared: 1, new: 1 });
});
it('should remove asset from snapshot when deleted', () => {
assetsService.setup(x => x.deleteAsset(app, oldAssets[0].id, version))
assetsService.setup(x => x.deleteAsset(app, asset1, version))
.returns(() => of(versioned(newVersion)));
assetsState.delete(oldAssets[0]).subscribe();
assetsState.delete(asset1).subscribe();
expect(assetsState.snapshot.assets.values.length).toBe(1);
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199);

2
src/Squidex/app/shared/state/assets.state.ts

@ -124,7 +124,7 @@ export class AssetsState extends State<Snapshot> {
}
public delete(asset: AssetDto): Observable<any> {
return this.assetsService.deleteAsset(this.appName, asset.id, asset.version).pipe(
return this.assetsService.deleteAsset(this.appName, asset, asset.version).pipe(
tap(() => {
return this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);

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

@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = await sut.ExecuteAsync(CreateAssetCommand(command));
result.ShouldBeEquivalent(new AssetSavedResult(0, 0, fileHash));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(0, sut.Snapshot.FileVersion);
Assert.Equal(fileHash, sut.Snapshot.FileHash);
@ -94,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = await sut.ExecuteAsync(CreateAssetCommand(command));
result.ShouldBeEquivalent(new AssetSavedResult(1, 1, fileHash));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal(1, sut.Snapshot.FileVersion);
Assert.Equal(fileHash, sut.Snapshot.FileHash);
@ -123,7 +123,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = await sut.ExecuteAsync(CreateAssetCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal("My New Image.png", sut.Snapshot.FileName);
@ -142,7 +142,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = await sut.ExecuteAsync(CreateAssetCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal("my-new-image.png", sut.Snapshot.Slug);
@ -161,7 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = await sut.ExecuteAsync(CreateAssetCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
result.ShouldBeEquivalent(sut.Snapshot);
LastEvents
.ShouldHaveSameEvents(

Loading…
Cancel
Save