Browse Source

Some more progress.

pull/356/head
Sebastian Stehle 8 years ago
parent
commit
e6925253ed
  1. 21
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  2. 35
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  3. 19
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  4. 3
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  5. 4
      src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  6. 14
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs
  7. 2
      src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  8. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  9. 8
      src/Squidex/app/framework/utils/rxjs-extensions.ts
  10. 15
      src/Squidex/app/shared/components/asset-uploader.component.html
  11. 32
      src/Squidex/app/shared/components/asset-uploader.component.scss
  12. 4
      src/Squidex/app/shared/components/asset-uploader.component.ts
  13. 2
      src/Squidex/app/shared/components/asset.component.html
  14. 5
      src/Squidex/app/shared/components/asset.component.ts
  15. 17
      src/Squidex/app/shared/components/assets-list.component.html
  16. 211
      src/Squidex/app/shared/state/asset-uploader.state.spec.ts
  17. 97
      src/Squidex/app/shared/state/asset-uploader.state.ts
  18. 2
      src/Squidex/app/shared/state/roles.state.ts
  19. 14
      src/Squidex/app/shared/state/schemas.state.ts
  20. 1027
      src/Squidex/package-lock.json
  21. 28
      src/Squidex/package.json
  22. 9
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  23. 8
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs
  24. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  25. 1
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

21
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -53,6 +53,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
new CreateIndexModel<MongoAssetEntity>( new CreateIndexModel<MongoAssetEntity>(
Index Index
.Ascending(x => x.AppId) .Ascending(x => x.AppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Slug)) .Ascending(x => x.Slug))
}, },
ct); ct);
@ -114,32 +115,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
using (Profiler.TraceMethod<MongoAssetRepository>()) using (Profiler.TraceMethod<MongoAssetRepository>())
{ {
var assetEntity = var assetEntity =
await Collection.Find(x => x.IndexedAppId == appId && x.Slug == slug) await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.Slug == slug)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (assetEntity?.IsDeleted == true)
{
return null;
}
return assetEntity; return assetEntity;
} }
} }
public async Task<IAssetEntity> FindAssetByHashAsync(Guid appId, string hash) public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{ {
using (Profiler.TraceMethod<MongoAssetRepository>()) using (Profiler.TraceMethod<MongoAssetRepository>())
{ {
var assetEntity = var assetEntities =
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.FileHash == hash) await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.FileHash == hash)
.FirstOrDefaultAsync(); .ToListAsync();
if (assetEntity?.IsDeleted == true)
{
return null;
}
return assetEntity; return assetEntities.OfType<IAssetEntity>().ToList();
} }
} }

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

@ -11,7 +11,6 @@ using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Entities.Tags; using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Assets;
@ -22,13 +21,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain> public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
{ {
private readonly IAssetStore assetStore; private readonly IAssetStore assetStore;
private readonly AssetQueryService assetQueryService; private readonly IAssetQueryService assetQueryService;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators; private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators;
public AssetCommandMiddleware( public AssetCommandMiddleware(
IGrainFactory grainFactory, IGrainFactory grainFactory,
AssetQueryService assetQueryService, IAssetQueryService assetQueryService,
IAssetStore assetStore, IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator, IAssetThumbnailGenerator assetThumbnailGenerator,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators) IEnumerable<ITagGenerator<CreateAsset>> tagGenerators)
@ -63,21 +62,27 @@ namespace Squidex.Domain.Apps.Entities.Assets
try try
{ {
var existing = await assetQueryService.FindAssetByHashAsync(createAsset.AppId.Id, createAsset.FileHash); var existings = await assetQueryService.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash);
AssetCreatedResult result; AssetCreatedResult result = null;
if (IsDuplicate(createAsset, existing)) foreach (var existing in existings)
{ {
result = new AssetCreatedResult( if (IsDuplicate(createAsset, existing))
existing.Id, {
existing.Tags, result = new AssetCreatedResult(
existing.Version, existing.Id,
existing.FileVersion, existing.Tags,
existing.FileHash, existing.Version,
true); existing.FileVersion,
existing.FileHash,
true);
}
break;
} }
else
if (result == null)
{ {
foreach (var tagGenerator in tagGenerators) foreach (var tagGenerator in tagGenerators)
{ {
@ -147,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
await assetStore.UploadAsync(context.ContextId.ToString(), hashStream); await assetStore.UploadAsync(context.ContextId.ToString(), hashStream);
hash = hashStream.GetHashStringAndReset(); hash = $"{hashStream.GetHashStringAndReset()}{file.FileName}{file.FileSize}".Sha256Base64();
} }
return hash; return hash;

19
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs

@ -38,14 +38,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
this.tagService = tagService; this.tagService = tagService;
} }
public virtual Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id) public Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
return FindAssetAsync(context.App.Id, id); return FindAssetAsync(context.App.Id, id);
} }
public virtual async Task<IAssetEntity> FindAssetAsync(Guid appId, Guid id) public async Task<IAssetEntity> FindAssetAsync(Guid appId, Guid id)
{ {
var asset = await assetRepository.FindAssetAsync(id); var asset = await assetRepository.FindAssetAsync(id);
@ -57,19 +57,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
return asset; return asset;
} }
public virtual async Task<IAssetEntity> FindAssetByHashAsync(Guid appId, string hash) public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{ {
var asset = await assetRepository.FindAssetByHashAsync(appId, hash); Guard.NotNull(hash, nameof(hash));
if (asset != null) var assets = await assetRepository.QueryByHashAsync(appId, hash);
{
await DenormalizeTagsAsync(appId, Enumerable.Repeat(asset, 1));
}
return asset; await DenormalizeTagsAsync(appId, assets);
return assets;
} }
public virtual async Task<IResultList<IAssetEntity>> QueryAsync(QueryContext context, Q query) public async Task<IResultList<IAssetEntity>> QueryAsync(QueryContext context, Q query)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query)); Guard.NotNull(query, nameof(query));

3
src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -13,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public interface IAssetQueryService public interface IAssetQueryService
{ {
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(QueryContext contex, Q query); Task<IResultList<IAssetEntity>> QueryAsync(QueryContext contex, Q query);
Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id); Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id);

4
src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{ {
public interface IAssetRepository public interface IAssetRepository
{ {
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, Query query); Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, Query query);
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids); Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids);
@ -23,8 +25,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
Task<IAssetEntity> FindAssetBySlugAsync(Guid appId, string slug); Task<IAssetEntity> FindAssetBySlugAsync(Guid appId, string slug);
Task<IAssetEntity> FindAssetByHashAsync(Guid appId, string hash);
Task RemoveAsync(Guid appId); Task RemoveAsync(Guid appId);
} }
} }

14
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs

@ -101,10 +101,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
AddField(new FieldType AddField(new FieldType
{ {
Name = "slug", Name = "fileHash",
ResolvedType = AllTypes.NonNullString, ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Slug), Resolver = Resolve(x => x.FileHash),
Description = "The file name as slug." Description = "The hash of the file. Can be null for old files."
}); });
AddField(new FieldType AddField(new FieldType
@ -131,6 +131,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = "The version of the file." Description = "The version of the file."
}); });
AddField(new FieldType
{
Name = "slug",
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Slug),
Description = "The file name as slug."
});
AddField(new FieldType AddField(new FieldType
{ {
Name = "isImage", Name = "isImage",

2
src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -108,7 +108,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
} }
else else
{ {
entity = await assetRepository.FindAssetByHashAsync(App.Id, idOrSlug); entity = await assetRepository.FindAssetBySlugAsync(App.Id, idOrSlug);
} }
return DeliverAsset(entity, version, width, height, quality, mode); return DeliverAsset(entity, version, width, height, quality, mode);

2
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -40,7 +40,7 @@
<div *ngSwitchCase="'Completed'" class="backup-status backup-status-success"> <div *ngSwitchCase="'Completed'" class="backup-status backup-status-success">
<i class="icon-checkmark"></i> <i class="icon-checkmark"></i>
</div> </div>
<div *NgSwitchDefault class="backup-status backup-status-pending spin"> <div *ngSwitchDefault class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i> <i class="icon-hour-glass"></i>
</div> </div>
</div> </div>

8
src/Squidex/app/framework/utils/rxjs-extensions.ts

@ -52,4 +52,12 @@ export function switchSafe<T, R>(project: (source: T) => Observable<R>) {
return function mapOperation(source: Observable<T>) { return function mapOperation(source: Observable<T>) {
return source.pipe(switchMap(project), onErrorResumeNext<R, R>()); return source.pipe(switchMap(project), onErrorResumeNext<R, R>());
}; };
}
export function ofForever<T>(...values: T[]) {
return new Observable<T>(s => {
for (let value of values) {
s.next(value);
}
});
} }

15
src/Squidex/app/shared/components/asset-uploader.component.html

@ -12,12 +12,23 @@
No upload in progress, drop files here. No upload in progress, drop files here.
</small> </small>
<div class="upload row no-gutters" *ngFor="let upload of uploads"> <div class="upload row no-gutters" *ngFor="let upload of uploads; trackBy: trackByUpload">
<div class="col-auto" [ngSwitch]="upload.status">
<div *ngSwitchCase="'Failed'" class="upload-status upload-status-failed">
<i class="icon-exclamation"></i>
</div>
<div *ngSwitchCase="'Completed'" class="upload-status upload-status-success">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchDefault class="upload-status upload-status-running">
<i class="icon-hour-glass"></i>
</div>
</div>
<div class="col-6"> <div class="col-6">
<div class="upload-name">{{upload.name}}</div> <div class="upload-name">{{upload.name}}</div>
</div> </div>
<div class="col"> <div class="col">
<sqx-progress-bar [value]="upload.progress" [trailWidth]="0.8" [strokeWidth]="0.8" [showText]="false" [animated]="false"></sqx-progress-bar> <sqx-progress-bar [value]="upload.progress" [trailWidth]="1.5" [strokeWidth]="1.5" [showText]="false" [animated]="false"></sqx-progress-bar>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="button" class="btn btn-text-secondary" (click)="stopUpload(upload)"> <button type="button" class="btn btn-text-secondary" (click)="stopUpload(upload)">

32
src/Squidex/app/shared/components/asset-uploader.component.scss

@ -37,10 +37,12 @@
.uploads { .uploads {
& { & {
border: 2px solid transparent; border: 2px solid transparent;
background: none;
min-height: 2rem;
} }
&-empty { &-empty {
line-height: 2rem; line-height: 1.8rem;
} }
} }
@ -63,6 +65,34 @@
} }
} }
$circle-size: 1.6rem;
.upload-status {
& {
@include circle($circle-size);
display: inline-block;
line-height: $circle-size + .1rem;
text-align: center;
font-size: .4 * $circle-size;
font-weight: normal;
background: $color-border;
color: $color-dark-foreground;
cursor: none;
}
&-running {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}
.drag { .drag {
& > .uploads { & > .uploads {
border-color: $color-theme-blue; border-color: $color-theme-blue;

4
src/Squidex/app/shared/components/asset-uploader.component.ts

@ -44,4 +44,8 @@ export class AssetUploaderComponent {
public stopUpload(upload: Upload) { public stopUpload(upload: Upload) {
this.assetUploader.stopUpload(upload); this.assetUploader.stopUpload(upload);
} }
public trackByUpload(index: number, upload: Upload) {
return upload.id;
}
} }

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

@ -1,4 +1,4 @@
<ng-container *ngIf="!isListView; else listTemplate"> <ng-container *ngIf="!isListView; else listTemplate">
<div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="emitSelect()" (sqxFileDrop)="updateFile($event)" [noDrop]="true"> <div class="card" [class.selectable]="isSelectable" [class.border-primary]="isSelected" (click)="emitSelect()" (sqxFileDrop)="updateFile($event)" [noDrop]="true">
<div class="card-body"> <div class="card-body">
<div class="file-preview" *ngIf="asset && snapshot.progress === 0" @fade> <div class="file-preview" *ngIf="asset && snapshot.progress === 0" @fade>

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

@ -9,7 +9,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Ho
import { import {
AssetDto, AssetDto,
AuthService,
DialogModel, DialogModel,
DialogService, DialogService,
fadeAnimation, fadeAnimation,
@ -113,7 +112,7 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
if (files.length === 1) { if (files.length === 1) {
this.setProgress(1); this.setProgress(1);
this.assetUploader.uploadUpdate(this.asset, files[0]) this.assetUploader.uploadAsset(this.asset, files[0])
.subscribe(dto => { .subscribe(dto => {
if (Types.isNumber(dto)) { if (Types.isNumber(dto)) {
this.setProgress(dto); this.setProgress(dto);
@ -123,6 +122,8 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
}, error => { }, error => {
this.dialogs.notifyError(error); this.dialogs.notifyError(error);
this.setProgress(0);
}, () => {
this.setProgress(0); this.setProgress(0);
}); });
} }

17
src/Squidex/app/shared/components/assets-list.component.html

@ -15,21 +15,20 @@
</div> </div>
<div class="row assets" [class.unrow]="isListView" *ngIf="state.tagsNames | async; let tags" (paste)="addFiles($event)"> <div class="row assets" [class.unrow]="isListView" *ngIf="state.tagsNames | async; let tags" (paste)="addFiles($event)">
<ng-container *ngIf="state.uploads | async; let uploads"> <sqx-asset *ngFor="let file of newFiles" [initFile]="file"
<sqx-asset *ngFor="let upload of uploads; trackBy: trackByUpload" [upload]="upload" [isListView]="isListView"
[isListView]="isListView"> (loadError)="remove(file)"
</sqx-asset> (load)="add(file, $event)">
</ng-container> </sqx-asset>
<ng-container *ngIf="state.assetsWithUploads | async; let assets"> <ng-container *ngIf="state.assets | async; let assets">
<sqx-asset *ngFor="let a of assets; trackBy: trackByAsset" [asset]="a.asset" [upload]="a.upload" <sqx-asset *ngFor="let asset of assets; trackBy: trackByAsset" [asset]="asset"
[isListView]="isListView" [isListView]="isListView"
[isDisabled]="isDisabled" [isDisabled]="isDisabled"
[isSelectable]="selectedIds" [isSelectable]="selectedIds"
[isSelected]="isSelected(a.asset)" [isSelected]="isSelected(asset)"
[allTags]="tags" [allTags]="tags"
(update)="update($event)" (update)="update($event)"
(uploadFile)="updateFile(a.asset, $event)"
(select)="emitSelect($event)" (select)="emitSelect($event)"
(delete)="delete($event)"> (delete)="delete($event)">
</sqx-asset> </sqx-asset>

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

@ -0,0 +1,211 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { never, of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, Mock } from 'typemoq';
import {
AssetUploaderState,
DialogService,
ofForever,
Types,
Version,
versioned
} from './../';
import { AssetDto, AssetReplacedDto, AssetsService } from '../services/assets.service';
import { TestValues } from './_test-helpers';
describe('AssetsState', () => {
const {
app,
appsState,
authService,
creator,
creation,
modified,
modifier,
now,
userId
} = TestValues;
let assetsService: IMock<AssetsService>;
let dialogs: IMock<DialogService>;
let assetUploader: AssetUploaderState;
const asset = new AssetDto('id1',
creator,
creator,
creation,
creation,
'my-asset',
'my-hash',
'png',
100,
1,
'image/png',
true,
true,
800,
600,
'my-slug',
[],
'http://url',
new Version('1'));
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
assetsService = Mock.ofType<AssetsService>();
assetUploader = new AssetUploaderState(appsState.object, assetsService.object, authService.object, dialogs.object);
});
afterEach(() => {
assetsService.verifyAll();
});
it('should create initial state when uploading file', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file, userId, now))
.returns(() => never()).verifiable();
assetUploader.uploadFile(file, undefined, now).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Running');
expect(upload.progress).toBe(1);
});
it('should update progress when uploading file makes progress', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file, userId, now))
.returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadFile(file, undefined, now).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Running');
expect(upload.progress).toBe(20);
});
it('should update status when uploading file failed', () => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file, userId, now))
.returns(() => throwError('Error')).verifiable();
assetUploader.uploadFile(file, undefined, now).pipe(onErrorResumeNext()).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Failed');
expect(upload.progress).toBe(1);
});
it('should update status when uploading file completes', (cb) => {
const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file, userId, now))
.returns(() => of(10, 20, asset)).verifiable();
let uploadedAsset: AssetDto;
assetUploader.uploadFile(file, undefined, now).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
cb();
});
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Completed');
expect(upload.progress).toBe(100);
expect(uploadedAsset!).toBe(asset);
});
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))
.returns(() => never()).verifiable();
assetUploader.uploadAsset(asset, file, now).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Running');
expect(upload.progress).toBe(1);
});
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))
.returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadAsset(asset, file, now).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Running');
expect(upload.progress).toBe(20);
});
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))
.returns(() => throwError('Error')).verifiable();
assetUploader.uploadAsset(asset, file, now).pipe(onErrorResumeNext()).subscribe();
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Failed');
expect(upload.progress).toBe(1);
});
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 newAsset = asset.update(update, modifier, new Version('2'), modified);
assetsService.setup(x => x.replaceFile(app, asset.id, file, asset.version))
.returns(() => of(10, 20, versioned(new Version('2'), update))).verifiable();
let uploadedAsset: AssetDto;
assetUploader.uploadAsset(asset, file, modified).subscribe(dto => {
if (Types.is(dto, AssetDto)) {
uploadedAsset = dto;
}
});
const upload = assetUploader.snapshot.uploads.at(0);
expect(upload.status).toBe('Completed');
expect(upload.progress).toBe(100);
expect(uploadedAsset!).toEqual(newAsset);
});
});

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

@ -6,8 +6,8 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, Subject, Subscription } from 'rxjs'; import { Observable, ReplaySubject, Subject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators';
import { import {
DateTime, DateTime,
@ -31,7 +31,10 @@ export interface Upload {
name: string; name: string;
// The upload subscription. // The upload subscription.
subscription: Subscription; subscription?: Subscription;
// The subject to notify subscribers.
subject: ReplaySubject<UploadResult>;
// The progress. // The progress.
progress: number; progress: number;
@ -64,7 +67,10 @@ export class AssetUploaderState extends State<Snapshot> {
} }
public stopUpload(upload: Upload) { public stopUpload(upload: Upload) {
upload.subscription.unsubscribe(); if (upload.subscription) {
upload.subscription.unsubscribe();
upload.subject.complete();
}
this.next(s => { this.next(s => {
const uploads = s.uploads.removeBy('id', upload); const uploads = s.uploads.removeBy('id', upload);
@ -74,76 +80,61 @@ export class AssetUploaderState extends State<Snapshot> {
} }
public uploadFile(file: File, target?: AssetsState, now?: DateTime): Observable<UploadResult> { public uploadFile(file: File, target?: AssetsState, now?: DateTime): Observable<UploadResult> {
const observable = this.assetsService.uploadFile(this.appName, file, this.user, now || DateTime.now()); const stream = this.assetsService.uploadFile(this.appName, file, this.user, now || DateTime.now());
let upload: Upload;
const subject = new Subject<UploadResult>();
const subscription = observable.subscribe(event => {
if (Types.isNumber(event)) {
this.update(upload, { progress: event });
} else {
if (event.isDuplicate) {
this.dialogs.notifyError('Asset has already been uploaded.');
}
if (target) { return this.upload(stream, MathHelper.guid(), file, (asset, subject) => {
target.add(event); if (asset.isDuplicate) {
} this.dialogs.notifyError('Asset has already been uploaded.');
} else if (target) {
target.add(asset);
} }
subject.next(event); subject.next(asset);
}, error => {
subject.error(error);
this.remove(upload, 'failed');
}, () => {
subject.complete();
this.remove(upload, 'completed');
}); });
}
upload = { id: MathHelper.guid(), name: file.name, progress: 1, subscription, status: 'running' }; public uploadAsset(asset: AssetDto, file: File, now?: DateTime): Observable<UploadResult> {
const stream = this.assetsService.replaceFile(this.appName, asset.id, file, asset.version);
this.addUpload(upload); return this.upload(stream, asset.id, file, ({ version, payload }, subject) => {
subject.next(asset.update(payload, this.user, version, now));
return subject; });
} }
public uploadUpdate(asset: AssetDto, file: File, now?: DateTime): Observable<UploadResult> { private upload<T, R>(stream: Observable<number | R>, id: string, file: File, complete: (completion: R, subject: Subject<UploadResult>) => void) {
const observable = this.assetsService.replaceFile(this.appName, asset.id, file, asset.version); const subject = new ReplaySubject<UploadResult>();
let upload = { id, name: file.name, progress: 1, status: 'Running', subject };
let upload: Upload; this.addUpload(upload);
const subject = new Subject<UploadResult>(); const subscription = stream.subscribe(event => {
const subscription = observable.subscribe(event => {
if (Types.isNumber(event)) { if (Types.isNumber(event)) {
this.update(upload, { progress: event }); upload = this.update(upload, { progress: event });
subject.next(event); subject.next(event);
} else { } else {
subject.next(asset.update(event.payload, this.user, event.version, now)); complete(event, subject);
} }
}, error => { }, error => {
subject.error(error); subject.error(error);
this.remove(upload, 'failed'); upload = this.remove(upload, { status: 'Failed' });
}, () => { }, () => {
subject.complete(); if (!subject.isStopped) {
subject.complete();
}
this.remove(upload, 'completed'); upload = this.remove(upload, { status: 'Completed', progress: 100 });
}); });
upload = { id: asset.id, name: file.name, progress: 1, subscription, status: 'running' }; upload = this.update(upload, { subscription });
this.addUpload(upload); return subject.pipe(shareReplay());
return subject;
} }
private remove(upload: Upload, status: string) { private remove(upload: Upload, update: Partial<Upload>) {
this.update(upload, { status }); upload = this.update(upload, update);
setTimeout(() => { setTimeout(() => {
this.next(s => { this.next(s => {
@ -153,14 +144,20 @@ export class AssetUploaderState extends State<Snapshot> {
}); });
}, 10000); }, 10000);
return upload;
} }
private update(upload: Upload, update: Partial<Upload>) { private update(upload: Upload, update: Partial<Upload>) {
upload = { ...upload, ...update };
this.next(s => { this.next(s => {
const uploads = s.uploads.replaceBy('id', { ...upload, ...update }); const uploads = s.uploads.replaceBy('id', upload);
return { ...s, uploads }; return { ...s, uploads };
}); });
return upload;
} }
private addUpload(upload: Upload) { private addUpload(upload: Upload) {

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

@ -64,7 +64,7 @@ export class RolesState extends State<Snapshot> {
} }
return this.rolesService.getRoles(this.appName).pipe( return this.rolesService.getRoles(this.appName).pipe(
tap(({ payload, version }) => { tap(({ version, payload }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('Roles reloaded.'); this.dialogs.notifyInfo('Roles reloaded.');
} }

14
src/Squidex/app/shared/state/schemas.state.ts

@ -271,7 +271,7 @@ export class SchemasState extends State<Snapshot> {
public lockField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> { public lockField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> {
return this.schemasService.lockField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.lockField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => setLocked(field, true)), mapVersioned(() => setLocked(field, true)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -280,7 +280,7 @@ export class SchemasState extends State<Snapshot> {
public enableField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> { public enableField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> {
return this.schemasService.enableField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.enableField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => setDisabled(field, false)), mapVersioned(() => setDisabled(field, false)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -289,7 +289,7 @@ export class SchemasState extends State<Snapshot> {
public disableField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> { public disableField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> {
return this.schemasService.disableField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.disableField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => setDisabled(field, true)), mapVersioned(() => setDisabled(field, true)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -298,7 +298,7 @@ export class SchemasState extends State<Snapshot> {
public showField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> { public showField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> {
return this.schemasService.showField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.showField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => setHidden(field, false)), mapVersioned(() => setHidden(field, false)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -307,7 +307,7 @@ export class SchemasState extends State<Snapshot> {
public hideField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> { public hideField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, now?: DateTime): Observable<T> {
return this.schemasService.hideField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.hideField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => setHidden(field, true)), mapVersioned(() => setHidden(field, true)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -316,7 +316,7 @@ export class SchemasState extends State<Snapshot> {
public updateField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, request: UpdateFieldDto, now?: DateTime): Observable<T> { public updateField<T extends FieldDto>(schema: SchemaDetailsDto, field: T, request: UpdateFieldDto, now?: DateTime): Observable<T> {
return this.schemasService.putField(this.appName, schema.name, field.fieldId, request, pidof(field), schema.version).pipe( return this.schemasService.putField(this.appName, schema.name, field.fieldId, request, pidof(field), schema.version).pipe(
mapVersioned(() => update(field, request.properties)), mapVersioned(() => update(field, request.properties)),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.replaceField(schema, payload, version, now); this.replaceField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs, { project: x => x.payload })); shareSubscribed(this.dialogs, { project: x => x.payload }));
@ -325,7 +325,7 @@ export class SchemasState extends State<Snapshot> {
public deleteField(schema: SchemaDetailsDto, field: AnyFieldDto, now?: DateTime): Observable<any> { public deleteField(schema: SchemaDetailsDto, field: AnyFieldDto, now?: DateTime): Observable<any> {
return this.schemasService.deleteField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe( return this.schemasService.deleteField(this.appName, schema.name, field.fieldId, pidof(field), schema.version).pipe(
mapVersioned(() => field), mapVersioned(() => field),
tap(({ payload, version }) => { tap(({ version, payload }) => {
this.removeField(schema, payload, version, now); this.removeField(schema, payload, version, now);
}), }),
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));

1027
src/Squidex/package-lock.json

File diff suppressed because it is too large

28
src/Squidex/package.json

@ -15,15 +15,15 @@
"build:clean": "rimraf wwwroot/build" "build:clean": "rimraf wwwroot/build"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "7.2.14", "@angular/animations": "7.2.3",
"@angular/common": "7.2.14", "@angular/common": "7.2.3",
"@angular/core": "7.2.14", "@angular/core": "7.2.3",
"@angular/forms": "7.2.14", "@angular/forms": "7.2.3",
"@angular/http": "7.2.14", "@angular/http": "7.2.3",
"@angular/platform-browser": "7.2.14", "@angular/platform-browser": "7.2.3",
"@angular/platform-browser-dynamic": "7.2.14", "@angular/platform-browser-dynamic": "7.2.3",
"@angular/platform-server": "7.2.14", "@angular/platform-server": "7.2.3",
"@angular/router": "7.2.14", "@angular/router": "7.2.3",
"angular2-chartjs": "0.5.1", "angular2-chartjs": "0.5.1",
"babel-polyfill": "6.26.0", "babel-polyfill": "6.26.0",
"bootstrap": "4.3.1", "bootstrap": "4.3.1",
@ -34,7 +34,7 @@
"moment": "2.24.0", "moment": "2.24.0",
"mousetrap": "1.6.3", "mousetrap": "1.6.3",
"ng2-dnd": "5.0.2", "ng2-dnd": "5.0.2",
"ngx-color-picker": "7.5.0", "ngx-color-picker": "7.3.0",
"oidc-client": "1.7.1", "oidc-client": "1.7.1",
"pikaday": "1.8.0", "pikaday": "1.8.0",
"progressbar.js": "1.0.1", "progressbar.js": "1.0.1",
@ -47,9 +47,9 @@
"zone.js": "0.9.0" "zone.js": "0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/compiler": "7.2.14", "@angular/compiler": "7.2.3",
"@angular/compiler-cli": "7.2.14", "@angular/compiler-cli": "7.2.3",
"@ngtools/webpack": "7.3.8", "@ngtools/webpack": "7.3.0",
"@types/core-js": "2.5.0", "@types/core-js": "2.5.0",
"@types/jasmine": "3.3.12", "@types/jasmine": "3.3.12",
"@types/marked": "0.6.5", "@types/marked": "0.6.5",
@ -84,7 +84,7 @@
"mini-css-extract-plugin": "0.6.0", "mini-css-extract-plugin": "0.6.0",
"node-sass": "4.12.0", "node-sass": "4.12.0",
"optimize-css-assets-webpack-plugin": "5.0.1", "optimize-css-assets-webpack-plugin": "5.0.1",
"raw-loader": "2.0.0", "raw-loader": "1.0.0",
"rimraf": "2.6.3", "rimraf": "2.6.3",
"rxjs-tslint": "0.1.7", "rxjs-tslint": "0.1.7",
"sass-lint": "1.13.1", "sass-lint": "1.13.1",

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

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public class AssetCommandMiddlewareTests : HandlerTestBase<AssetState> public class AssetCommandMiddlewareTests : HandlerTestBase<AssetState>
{ {
private readonly AssetQueryService assetQueryService = A.Fake<AssetQueryService>(); private readonly IAssetQueryService assetQueryService = A.Fake<IAssetQueryService>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<MemoryAssetStore>(); private readonly IAssetStore assetStore = A.Fake<MemoryAssetStore>();
private readonly ITagService tagService = A.Fake<ITagService>(); private readonly ITagService tagService = A.Fake<ITagService>();
@ -52,6 +52,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>()); asset = new AssetGrain(Store, tagService, A.Dummy<ISemanticLog>());
asset.ActivateAsync(Id).Wait(); asset.ActivateAsync(Id).Wait();
A.CallTo(() => assetQueryService.QueryByHashAsync(AppId, A<string>.Ignored))
.Returns(new List<IAssetEntity>());
A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>.Ignored, A<HashSet<string>>.Ignored)) A.CallTo(() => tagService.NormalizeTagsAsync(AppId, TagGroups.Assets, A<HashSet<string>>.Ignored, A<HashSet<string>>.Ignored))
.Returns(new Dictionary<string, string>()); .Returns(new Dictionary<string, string>());
@ -202,8 +205,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
A.CallTo(() => temp.FileName).Returns(fileName); A.CallTo(() => temp.FileName).Returns(fileName);
A.CallTo(() => temp.FileSize).Returns(fileSize); A.CallTo(() => temp.FileSize).Returns(fileSize);
A.CallTo(() => assetQueryService.FindAssetByHashAsync(A<Guid>.Ignored, A<string>.Ignored)) A.CallTo(() => assetQueryService.QueryByHashAsync(A<Guid>.Ignored, A<string>.Ignored))
.Returns(existing); .Returns(new List<IAssetEntity> { existing });
} }
private void SetupImageInfo() private void SetupImageInfo()

8
tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetQueryServiceTests.cs

@ -70,12 +70,12 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
var id = Guid.NewGuid(); var id = Guid.NewGuid();
A.CallTo(() => assetRepository.FindAssetByHashAsync(appId.Id, "hash")) A.CallTo(() => assetRepository.QueryByHashAsync(appId.Id, "hash"))
.Returns(CreateAsset(id, "id1", "id2", "id3")); .Returns(new List<IAssetEntity> { CreateAsset(id, "id1", "id2", "id3") });
var result = await sut.FindAssetByHashAsync(appId.Id, "hash"); var result = await sut.QueryByHashAsync(appId.Id, "hash");
Assert.Equal(HashSet.Of("name1", "name2", "name3"), result.Tags); Assert.Equal(HashSet.Of("name1", "name2", "name3"), result[0].Tags);
} }
[Fact] [Fact]

6
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -51,6 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl sourceUrl
mimeType mimeType
fileName fileName
fileHash
fileSize fileSize
fileVersion fileVersion
isImage isImage
@ -86,6 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl = $"assets/source/{asset.Id}", sourceUrl = $"assets/source/{asset.Id}",
mimeType = "image/png", mimeType = "image/png",
fileName = "MyFile.png", fileName = "MyFile.png",
fileHash = "ABC123",
fileSize = 1024, fileSize = 1024,
fileVersion = 123, fileVersion = 123,
isImage = true, isImage = true,
@ -119,6 +121,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl sourceUrl
mimeType mimeType
fileName fileName
fileHash
fileSize fileSize
fileVersion fileVersion
isImage isImage
@ -158,6 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl = $"assets/source/{asset.Id}", sourceUrl = $"assets/source/{asset.Id}",
mimeType = "image/png", mimeType = "image/png",
fileName = "MyFile.png", fileName = "MyFile.png",
fileHash = "ABC123",
fileSize = 1024, fileSize = 1024,
fileVersion = 123, fileVersion = 123,
isImage = true, isImage = true,
@ -193,6 +197,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl sourceUrl
mimeType mimeType
fileName fileName
fileHash
fileSize fileSize
fileVersion fileVersion
isImage isImage
@ -224,6 +229,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sourceUrl = $"assets/source/{asset.Id}", sourceUrl = $"assets/source/{asset.Id}",
mimeType = "image/png", mimeType = "image/png",
fileName = "MyFile.png", fileName = "MyFile.png",
fileHash = "ABC123",
fileSize = 1024, fileSize = 1024,
fileVersion = 123, fileVersion = 123,
isImage = true, isImage = true,

1
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -172,6 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
FileName = "MyFile.png", FileName = "MyFile.png",
Slug = "myfile.png", Slug = "myfile.png",
FileSize = 1024, FileSize = 1024,
FileHash = "ABC123",
FileVersion = 123, FileVersion = 123,
MimeType = "image/png", MimeType = "image/png",
IsImage = true, IsImage = true,

Loading…
Cancel
Save